sourcecode 1.35.35__tar.gz → 1.36.0__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 (114) hide show
  1. {sourcecode-1.35.35 → sourcecode-1.36.0}/PKG-INFO +3 -3
  2. {sourcecode-1.35.35 → sourcecode-1.36.0}/README.md +2 -2
  3. {sourcecode-1.35.35 → sourcecode-1.36.0}/pyproject.toml +1 -1
  4. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/cli.py +62 -0
  6. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/license.py +21 -1
  7. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/telemetry/__init__.py +10 -2
  8. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/telemetry/config.py +23 -0
  9. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/telemetry/events.py +5 -0
  10. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/telemetry/filters.py +28 -0
  11. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/telemetry/transport.py +1 -1
  12. sourcecode-1.36.0/supabase/functions/README.md +39 -0
  13. sourcecode-1.36.0/supabase/functions/get-license/index.ts +83 -0
  14. sourcecode-1.36.0/supabase/functions/lemonsqueezy-webhook/index.ts +163 -0
  15. sourcecode-1.36.0/supabase/functions/telemetry/index.ts +72 -0
  16. sourcecode-1.36.0/supabase/sql/telemetry_events.sql +36 -0
  17. {sourcecode-1.35.35 → sourcecode-1.36.0}/.github/workflows/build-windows.yml +0 -0
  18. {sourcecode-1.35.35 → sourcecode-1.36.0}/.gitignore +0 -0
  19. {sourcecode-1.35.35 → sourcecode-1.36.0}/.ruff.toml +0 -0
  20. {sourcecode-1.35.35 → sourcecode-1.36.0}/CHANGELOG.md +0 -0
  21. {sourcecode-1.35.35 → sourcecode-1.36.0}/CONTRIBUTING.md +0 -0
  22. {sourcecode-1.35.35 → sourcecode-1.36.0}/LICENSE +0 -0
  23. {sourcecode-1.35.35 → sourcecode-1.36.0}/SECURITY.md +0 -0
  24. {sourcecode-1.35.35 → sourcecode-1.36.0}/raw +0 -0
  25. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/adaptive_scanner.py +0 -0
  26. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/architecture_analyzer.py +0 -0
  27. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/architecture_summary.py +0 -0
  28. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/ast_extractor.py +0 -0
  29. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/cache.py +0 -0
  30. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/canonical_ir.py +0 -0
  31. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/cir_graphs.py +0 -0
  32. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/classifier.py +0 -0
  33. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  34. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/confidence_analyzer.py +0 -0
  35. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/context_scorer.py +0 -0
  36. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/context_summarizer.py +0 -0
  37. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/contract_model.py +0 -0
  38. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/contract_pipeline.py +0 -0
  39. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/coverage_parser.py +0 -0
  40. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/dependency_analyzer.py +0 -0
  41. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/__init__.py +0 -0
  42. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/base.py +0 -0
  43. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  44. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/dart.py +0 -0
  45. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/dotnet.py +0 -0
  46. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/elixir.py +0 -0
  47. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/go.py +0 -0
  48. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/heuristic.py +0 -0
  49. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/hybrid.py +0 -0
  50. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/java.py +0 -0
  51. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  52. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/nodejs.py +0 -0
  53. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/parsers.py +0 -0
  54. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/php.py +0 -0
  55. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/project.py +0 -0
  56. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/python.py +0 -0
  57. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/ruby.py +0 -0
  58. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/rust.py +0 -0
  59. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/systems.py +0 -0
  60. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/terraform.py +0 -0
  61. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/detectors/tooling.py +0 -0
  62. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/doc_analyzer.py +0 -0
  63. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  64. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/env_analyzer.py +0 -0
  65. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/error_schema.py +0 -0
  66. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/explain.py +0 -0
  67. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/file_chunker.py +0 -0
  68. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/file_classifier.py +0 -0
  69. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/flow_analyzer.py +0 -0
  70. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/fqn_utils.py +0 -0
  71. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/git_analyzer.py +0 -0
  72. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/graph_analyzer.py +0 -0
  73. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/__init__.py +0 -0
  74. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  75. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  76. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  77. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  78. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  79. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  80. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/registry.py +0 -0
  81. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/runner.py +0 -0
  82. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp/server.py +0 -0
  83. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/mcp_nudge.py +0 -0
  84. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/metrics_analyzer.py +0 -0
  85. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/migrate_check.py +0 -0
  86. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/output_budget.py +0 -0
  87. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/path_filters.py +0 -0
  88. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  89. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/pr_impact.py +0 -0
  90. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/prepare_context.py +0 -0
  91. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/progress.py +0 -0
  92. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/ranking_engine.py +0 -0
  93. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/redactor.py +0 -0
  94. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/relevance_scorer.py +0 -0
  95. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/rename_refactor.py +0 -0
  96. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/repo_classifier.py +0 -0
  97. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/repository_ir.py +0 -0
  98. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/ris.py +0 -0
  99. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/runtime_classifier.py +0 -0
  100. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/scanner.py +0 -0
  101. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/schema.py +0 -0
  102. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/semantic_analyzer.py +0 -0
  103. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/serializer.py +0 -0
  104. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_event_topology.py +0 -0
  105. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_findings.py +0 -0
  106. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_impact.py +0 -0
  107. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_model.py +0 -0
  108. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_security_audit.py +0 -0
  109. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_semantic.py +0 -0
  110. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  111. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/summarizer.py +0 -0
  112. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/telemetry/consent.py +0 -0
  113. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/tree_utils.py +0 -0
  114. {sourcecode-1.35.35 → sourcecode-1.36.0}/src/sourcecode/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.35
3
+ Version: 1.36.0
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
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.35.35-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.36.0-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.35.35
117
+ # sourcecode 1.36.0
118
118
  ```
119
119
 
120
120
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
4
4
 
5
- ![Version](https://img.shields.io/badge/version-1.35.35-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.36.0-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
7
7
 
8
8
  ---
@@ -76,7 +76,7 @@ pipx install sourcecode
76
76
 
77
77
  ```bash
78
78
  sourcecode version
79
- # sourcecode 1.35.35
79
+ # sourcecode 1.36.0
80
80
  ```
81
81
 
82
82
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.35.35"
7
+ version = "1.36.0"
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.35.35"
3
+ __version__ = "1.36.0"
@@ -653,6 +653,38 @@ def version_callback(value: bool) -> None:
653
653
  raise typer.Exit()
654
654
 
655
655
 
656
+ def _print_welcome() -> None:
657
+ """Branded quickstart shown only on a bare invocation at a human terminal.
658
+
659
+ Agents and pipes never reach here: they either pass args/flags or stdout is
660
+ not a TTY, so the JSON machine contract is completely unchanged.
661
+ """
662
+ try:
663
+ from sourcecode import license as _lic
664
+ tier = "Pro" if _lic.is_pro else "Free"
665
+ except Exception:
666
+ tier = "Free"
667
+
668
+ lines = [
669
+ "",
670
+ f" sourcecode {__version__} · {tier}",
671
+ "",
672
+ " Structural context for AI coding agents — analyze a repo, get",
673
+ " LLM-ready JSON in milliseconds.",
674
+ "",
675
+ " Get started:",
676
+ " sourcecode --compact high-signal summary of this repo",
677
+ " sourcecode prepare-context onboard full onboarding context",
678
+ " sourcecode mcp init connect Claude / Cursor",
679
+ "",
680
+ " sourcecode --help all commands",
681
+ ]
682
+ if tier != "Pro":
683
+ lines.append(" sourcecode activate <key> unlock Pro (large repos)")
684
+ lines.append("")
685
+ typer.echo("\n".join(lines))
686
+
687
+
656
688
  @app.callback(invoke_without_command=True)
657
689
  def main(
658
690
  ctx: typer.Context,
@@ -902,6 +934,13 @@ def main(
902
934
  if ctx.invoked_subcommand is not None:
903
935
  return
904
936
 
937
+ # Bare invocation at a human terminal → branded quickstart instead of
938
+ # dumping a large JSON blob. Agents/pipes are untouched: any arg/flag, or a
939
+ # non-TTY stdout (piped), falls through to the normal analysis + JSON.
940
+ if len(sys.argv) <= 1 and sys.stdout.isatty():
941
+ _print_welcome()
942
+ raise typer.Exit()
943
+
905
944
  _t0 = time.monotonic()
906
945
  no_tree: bool = False # set True by --agent; --no-tree flag removed
907
946
 
@@ -2797,6 +2836,17 @@ def prepare_context_cmd(
2797
2836
  _cached_pctx = _pctx_cache.read(target, _pctx_cache_key)
2798
2837
  if _cached_pctx is not None:
2799
2838
  _emit_command_output(_cached_pctx, output_path, copy)
2839
+ try:
2840
+ from sourcecode import telemetry as _tel
2841
+ _tel.record(
2842
+ "execution_completed",
2843
+ cmd="prepare-context",
2844
+ feature=task,
2845
+ output_fmt=format,
2846
+ duration_s=0.0,
2847
+ )
2848
+ except Exception:
2849
+ pass
2800
2850
  return
2801
2851
 
2802
2852
  builder = TaskContextBuilder(target)
@@ -3192,6 +3242,18 @@ def prepare_context_cmd(
3192
3242
 
3193
3243
  _emit_command_output(_pc_content, output_path, copy)
3194
3244
 
3245
+ try:
3246
+ from sourcecode import telemetry as _tel
3247
+ _tel.record(
3248
+ "execution_completed",
3249
+ cmd="prepare-context",
3250
+ feature=task,
3251
+ output_fmt=format,
3252
+ duration_s=_time.perf_counter() - _t0,
3253
+ )
3254
+ except Exception:
3255
+ pass
3256
+
3195
3257
  from sourcecode.mcp_nudge import nudge_mcp_if_needed as _nudge
3196
3258
  _nudge()
3197
3259
 
@@ -40,10 +40,15 @@ from typing import Optional
40
40
  # Supabase endpoint config — hardcoded for production; override via env for dev
41
41
  # ---------------------------------------------------------------------------
42
42
  _DEFAULT_SUPABASE_URL: str = "https://qkndlmyekvujjdgthtmz.supabase.co"
43
+ # Public anon/publishable key — safe to ship in client code. RLS plus the
44
+ # service-role key (server-only, in the Edge Function secrets) protect the data.
45
+ # Paste your project's anon key here so `sourcecode activate` works out of the
46
+ # box; env var still overrides for testing against another project.
47
+ _DEFAULT_SUPABASE_ANON_KEY: str = "sb_publishable_qiJFLWjbBbTqjg-fb0mAGA_cl8PBOKH"
43
48
  _SUPABASE_URL: str = os.environ.get("SOURCECODE_SUPABASE_URL", _DEFAULT_SUPABASE_URL)
44
49
  _SUPABASE_ANON_KEY: str = os.environ.get(
45
50
  "SOURCECODE_SUPABASE_ANON_KEY",
46
- "", # Set SOURCECODE_SUPABASE_ANON_KEY to your project anon key
51
+ _DEFAULT_SUPABASE_ANON_KEY,
47
52
  )
48
53
  if _SUPABASE_URL != _DEFAULT_SUPABASE_URL:
49
54
  sys.stderr.write(
@@ -411,6 +416,15 @@ _init()
411
416
  # Entitlement helpers
412
417
  # ---------------------------------------------------------------------------
413
418
 
419
+ def _emit_telemetry(event: str, **kw: object) -> None:
420
+ """Best-effort telemetry emit. Respects opt-in; never raises or blocks."""
421
+ try:
422
+ from sourcecode import telemetry as _tel
423
+ _tel.record(event, **kw) # type: ignore[arg-type]
424
+ except Exception:
425
+ pass
426
+
427
+
414
428
  def can_use(feature_name: str) -> bool:
415
429
  """Return True if the current plan has access to feature_name.
416
430
 
@@ -505,6 +519,7 @@ def require_feature(
505
519
  }
506
520
  if extra_fields:
507
521
  payload.update(extra_fields)
522
+ _emit_telemetry("gate_blocked", feature=feature_name, success=False)
508
523
  _emit_upgrade_and_exit(
509
524
  f"'{display}' is a Pro feature.",
510
525
  [info.get("description", ""), info.get("value", "")],
@@ -555,6 +570,7 @@ def require_repo_or_pro(
555
570
  }
556
571
  if extra_fields:
557
572
  payload.update(extra_fields)
573
+ _emit_telemetry("gate_blocked", feature=feature_name, repo_size="large", success=False)
558
574
  _emit_upgrade_and_exit(headline, body, payload)
559
575
 
560
576
 
@@ -605,6 +621,7 @@ def _finish_device_auth(result: dict) -> None:
605
621
  _write_license_file(data)
606
622
  _license_data = data
607
623
  is_pro = plan == "pro" and plan_status != "inactive"
624
+ _emit_telemetry("activation", feature="device_flow", success=is_pro)
608
625
 
609
626
  sys.stderr.write(f"\n Authenticated as {email}. Plan: {plan}\n\n")
610
627
  sys.stderr.flush()
@@ -688,9 +705,11 @@ def activate_license(license_key: str) -> None:
688
705
  _fail("network_error", "Could not reach license server. Check your internet connection.")
689
706
 
690
707
  if not result.get("valid"):
708
+ _emit_telemetry("activation", feature="key", success=False, error_kind="InvalidLicense")
691
709
  _fail("invalid_license", result.get("error", "License key is not valid or subscription is inactive."))
692
710
 
693
711
  if result.get("plan") != "pro":
712
+ _emit_telemetry("activation", feature="key", success=False, error_kind="NotPro")
694
713
  _fail("not_pro", "This license is not a Pro license.")
695
714
 
696
715
  _LICENSE_DIR.mkdir(parents=True, exist_ok=True)
@@ -704,6 +723,7 @@ def activate_license(license_key: str) -> None:
704
723
  "validated_at": now,
705
724
  }
706
725
  _write_license_file(data)
726
+ _emit_telemetry("activation", feature="key", success=True)
707
727
 
708
728
  output = {"status": "activated", "plan": "pro", "features": data["features"]}
709
729
  sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
@@ -19,7 +19,7 @@ import sys
19
19
  import uuid
20
20
  from typing import Any, Optional
21
21
 
22
- from sourcecode.telemetry.config import is_enabled
22
+ from sourcecode.telemetry.config import get_install_id, is_enabled
23
23
  from sourcecode.telemetry.events import (
24
24
  TelemetryEvent,
25
25
  duration_bucket,
@@ -77,6 +77,8 @@ def record(
77
77
  duration_s: Optional[float] = None,
78
78
  success: bool = True,
79
79
  error_kind: Optional[str] = None,
80
+ feature: Optional[str] = None,
81
+ repo_size: Optional[str] = None,
80
82
  ) -> None:
81
83
  """Record a telemetry event. Fire-and-forget — never blocks or raises.
82
84
 
@@ -96,10 +98,16 @@ def record(
96
98
  cmd=cmd,
97
99
  flags=flags or [],
98
100
  output_fmt=output_fmt,
99
- repo_size=file_count_bucket(file_count) if file_count is not None else "unknown",
101
+ repo_size=(
102
+ repo_size if repo_size is not None
103
+ else file_count_bucket(file_count) if file_count is not None
104
+ else "unknown"
105
+ ),
100
106
  duration=duration_bucket(duration_s) if duration_s is not None else "unknown",
101
107
  success=success,
102
108
  error_kind=error_kind,
109
+ feature=feature,
110
+ install=get_install_id(),
103
111
  session=_SESSION,
104
112
  )
105
113
  payload = sanitize(ev)
@@ -63,5 +63,28 @@ def mark_asked() -> None:
63
63
  _save(data)
64
64
 
65
65
 
66
+ def get_install_id() -> str:
67
+ """Stable anonymous install id — a random UUID v4.
68
+
69
+ Created lazily on first opted-in event. NOT derived from hardware, email,
70
+ hostname or any identifier — it only says "the same install across runs",
71
+ which is what enables unique-user, conversion and retention metrics.
72
+ Returns "" if it cannot be persisted (telemetry then degrades to events
73
+ without a stable id, never an error).
74
+ """
75
+ data = _load()
76
+ tel = data.setdefault("telemetry", {})
77
+ iid = tel.get("install_id")
78
+ if not iid:
79
+ import uuid
80
+ iid = str(uuid.uuid4())
81
+ tel["install_id"] = iid
82
+ _save(data)
83
+ # If the write failed, re-read to avoid handing out a non-persisted id
84
+ if not _load().get("telemetry", {}).get("install_id"):
85
+ return ""
86
+ return str(iid)
87
+
88
+
66
89
  def config_file_path() -> Path:
67
90
  return _CONFIG_FILE
@@ -59,6 +59,9 @@ class TelemetryEvent:
59
59
  duration — <1s | <5s | <15s | <60s | 60s+
60
60
  success — True/False
61
61
  error_kind — exception class name only (no message, no traceback)
62
+ feature — gated feature / task name (closed categorical set) or None
63
+ install — stable anonymous install UUID (random, no PII); enables
64
+ unique-user / conversion / retention metrics
62
65
  session — 8-char random hex, ephemeral, NOT persisted
63
66
  """
64
67
 
@@ -75,4 +78,6 @@ class TelemetryEvent:
75
78
  duration: str = "unknown"
76
79
  success: bool = True
77
80
  error_kind: Optional[str] = None
81
+ feature: Optional[str] = None
82
+ install: str = ""
78
83
  session: str = ""
@@ -63,6 +63,19 @@ _SAFE_EVENTS: frozenset[str] = frozenset({
63
63
  "execution_failed",
64
64
  "telemetry_enabled",
65
65
  "telemetry_disabled",
66
+ "gate_blocked",
67
+ "activation",
68
+ })
69
+ # Closed set of gated features / task names. Used to learn which capability
70
+ # drives Pro demand. All values are fixed product identifiers — no user data.
71
+ _SAFE_FEATURES: frozenset[str] = frozenset({
72
+ # gated features (license._FEATURE_INFO keys)
73
+ "impact", "modernize", "fix-bug", "review-pr", "delta", "generate-tests",
74
+ "--full", "git-history", "multi-repo", "export-rich", "team-snapshots",
75
+ # prepare-context task names not already above
76
+ "explain", "onboard", "refactor",
77
+ # activation outcomes
78
+ "key", "device_flow",
66
79
  })
67
80
  _SAFE_SIZES: frozenset[str] = frozenset({"tiny", "small", "medium", "large", "huge", "unknown"})
68
81
  _SAFE_DURATIONS: frozenset[str] = frozenset({"<1s", "<5s", "<15s", "<60s", "60s+", "unknown"})
@@ -103,6 +116,14 @@ def _safe_session(value: str) -> str:
103
116
  return ""
104
117
 
105
118
 
119
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
120
+
121
+
122
+ def _safe_install(value: str) -> str:
123
+ """Install id must be a canonical UUID — nothing else can pass."""
124
+ return value if value and _UUID_RE.match(value) else ""
125
+
126
+
106
127
  def sanitize(event: TelemetryEvent) -> dict[str, Any]:
107
128
  """Apply privacy filter to event and return a safe dict for transmission.
108
129
 
@@ -127,6 +148,13 @@ def sanitize(event: TelemetryEvent) -> dict[str, Any]:
127
148
  if event.error_kind:
128
149
  safe["error_kind"] = _safe_error_kind(event.error_kind)
129
150
 
151
+ if event.feature:
152
+ safe["feature"] = _safe_str(event.feature, _SAFE_FEATURES, "other")
153
+
154
+ install = _safe_install(event.install)
155
+ if install:
156
+ safe["install"] = install
157
+
130
158
  session = _safe_session(event.session)
131
159
  if session:
132
160
  safe["session"] = session
@@ -15,7 +15,7 @@ import os
15
15
  import threading
16
16
  from typing import Any
17
17
 
18
- _DEFAULT_ENDPOINT = "https://t.sourcecode.dev/v1/event"
18
+ _DEFAULT_ENDPOINT = "https://qkndlmyekvujjdgthtmz.supabase.co/functions/v1/telemetry"
19
19
  _TIMEOUT_S = 3
20
20
 
21
21
 
@@ -0,0 +1,39 @@
1
+ # Supabase Edge Functions
2
+
3
+ Backend for the Pro license flow. The CLI side lives in
4
+ `src/sourcecode/license.py`.
5
+
6
+ ## Functions
7
+
8
+ | Function | Purpose | JWT |
9
+ |----------|---------|-----|
10
+ | `get-license` | Validates a license key for `sourcecode activate` and the 30-min revalidation. Returns `{valid, plan, status, features, email}`. | `--no-verify-jwt` |
11
+ | `lemonsqueezy-webhook` | Lemon Squeezy purchase/subscription webhook. Stores the LS native key, sets plan/status, handles revocation. | `--no-verify-jwt` |
12
+ | `telemetry` | Collects opt-in anonymous usage events (no PII). Inserts into `telemetry_events`. CLI side: `src/sourcecode/telemetry/`. | `--no-verify-jwt` |
13
+
14
+ All deploy with JWT verification OFF: the CLI authenticates with the public
15
+ publishable key (not a JWT), the webhook authenticates via HMAC signature, and
16
+ telemetry is unauthenticated public ingest.
17
+
18
+ The `telemetry` table is created from `supabase/sql/telemetry_events.sql`.
19
+
20
+ ## Secrets (Supabase dashboard -> Edge Functions -> Secrets)
21
+
22
+ - `SUPABASE_URL`
23
+ - `SUPABASE_SERVICE_ROLE_KEY`
24
+ - `LEMON_SQUEEZY_WEBHOOK_SECRET` (webhook only)
25
+
26
+ ## Deploy
27
+
28
+ ```bash
29
+ supabase functions deploy get-license --no-verify-jwt
30
+ supabase functions deploy lemonsqueezy-webhook --no-verify-jwt
31
+ ```
32
+
33
+ ## Lemon Squeezy config
34
+
35
+ - Keep **Generate license keys** ON for every Pro variant (LS emails the key;
36
+ the webhook stores that same native key — single key system).
37
+ - Subscribe the webhook to: `license_key_created`, `order_created`,
38
+ `subscription_created/updated/resumed/unpaused`,
39
+ `subscription_payment_success`, `subscription_expired`, `subscription_paused`.
@@ -0,0 +1,83 @@
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
+ // License validation endpoint hit by the CLI's `sourcecode activate <key>` and
5
+ // by the 30-min background revalidation (license.py: _call_get_license).
6
+ // Deploy with --no-verify-jwt: the CLI authenticates with the public
7
+ // publishable key, which is not a legacy-secret JWT. Protection is the exact
8
+ // license_key the caller must present + service-role lookup, not a JWT.
9
+ const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
10
+ const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
11
+
12
+ // Same format the CLI validates (license.py:72)
13
+ const LICENSE_KEY_RE = /^[A-Za-z0-9_\-]{1,200}$/;
14
+
15
+ const json = (body: unknown, status = 200) =>
16
+ new Response(JSON.stringify(body), {
17
+ status,
18
+ headers: { "Content-Type": "application/json" },
19
+ });
20
+
21
+ serve(async (req: Request) => {
22
+ if (req.method !== "POST") {
23
+ return json({ valid: false, error: "method_not_allowed" }, 405);
24
+ }
25
+
26
+ let payload: Record<string, unknown>;
27
+ try {
28
+ payload = await req.json();
29
+ } catch {
30
+ return json({ valid: false, error: "invalid_json" }, 400);
31
+ }
32
+
33
+ const licenseKey = ((payload.license_key as string) ?? "").trim();
34
+ if (!licenseKey || !LICENSE_KEY_RE.test(licenseKey)) {
35
+ return json({ valid: false, error: "invalid_license_format" });
36
+ }
37
+
38
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
39
+
40
+ const { data: user, error } = await supabase
41
+ .from("users")
42
+ .select("email, plan, status, features")
43
+ .eq("license_key", licenseKey)
44
+ .maybeSingle();
45
+
46
+ if (error) {
47
+ console.error("DB error", error);
48
+ return json({ valid: false, error: "db_error" }, 500);
49
+ }
50
+
51
+ if (!user) {
52
+ return json({ valid: false, error: "license_not_found" });
53
+ }
54
+
55
+ const active = (user.status ?? "active") === "active";
56
+ const isPro = user.plan === "pro";
57
+
58
+ // Revocation: status != active OR plan != pro -> valid:false.
59
+ // The CLI revalidates every 30 min and clears its cache on this response.
60
+ if (!active || !isPro) {
61
+ return json({
62
+ valid: false,
63
+ error: !isPro ? "not_pro" : "inactive",
64
+ plan: user.plan ?? "free",
65
+ status: user.status ?? "inactive",
66
+ });
67
+ }
68
+
69
+ // features may arrive as jsonb (array) or as a JSON string — normalize
70
+ let features = user.features as unknown;
71
+ if (typeof features === "string") {
72
+ try { features = JSON.parse(features); } catch { features = []; }
73
+ }
74
+ if (!Array.isArray(features)) features = [];
75
+
76
+ return json({
77
+ valid: true,
78
+ plan: user.plan,
79
+ status: user.status ?? "active",
80
+ features,
81
+ email: user.email ?? "",
82
+ });
83
+ });
@@ -0,0 +1,163 @@
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
+ });
@@ -0,0 +1,72 @@
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
+ // Opt-in anonymous usage telemetry collector. The CLI sends privacy-filtered
5
+ // events (no PII, no paths) fire-and-forget — see src/sourcecode/telemetry/.
6
+ // Deploy with --no-verify-jwt: events are public, low-value, and the client
7
+ // sends no auth header. This endpoint only INSERTS into telemetry_events.
8
+ const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
9
+ const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
10
+
11
+ // Server-side defense in depth — mirror the client allowlists. Anything
12
+ // unexpected is coerced to a safe default so a tampered payload cannot inject.
13
+ const EVENTS = new Set([
14
+ "command_executed", "execution_completed", "execution_failed",
15
+ "telemetry_enabled", "telemetry_disabled", "gate_blocked", "activation",
16
+ ]);
17
+ const SIZES = new Set(["tiny", "small", "medium", "large", "huge", "unknown"]);
18
+ const DURATIONS = new Set(["<1s", "<5s", "<15s", "<60s", "60s+", "unknown"]);
19
+ const OSES = new Set(["linux", "macos", "windows", "other"]);
20
+ const ARCHES = new Set(["x64", "arm64", "other"]);
21
+ const FMTS = new Set(["json", "yaml"]);
22
+
23
+ const pick = (v: unknown, allowed: Set<string>, fb: string) =>
24
+ typeof v === "string" && allowed.has(v) ? v : fb;
25
+ const short = (v: unknown, n = 32) =>
26
+ typeof v === "string" && v.length <= n && !/[/\\\s]/.test(v) ? v : null;
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
28
+ const uuid = (v: unknown) =>
29
+ typeof v === "string" && UUID_RE.test(v) ? v : null;
30
+
31
+ serve(async (req: Request) => {
32
+ if (req.method !== "POST") return new Response("ok", { status: 405 });
33
+
34
+ let p: Record<string, unknown>;
35
+ try {
36
+ p = await req.json();
37
+ } catch {
38
+ return new Response("ok", { status: 200 }); // never error loudly on telemetry
39
+ }
40
+
41
+ const row = {
42
+ event: pick(p.event, EVENTS, "command_executed"),
43
+ client_ts: short(p.ts, 20),
44
+ v: short(p.v, 16),
45
+ py: short(p.py, 8),
46
+ os: pick(p.os, OSES, "other"),
47
+ arch: pick(p.arch, ARCHES, "other"),
48
+ cmd: short(p.cmd, 24),
49
+ flags: Array.isArray(p.flags) ? p.flags.filter((f) => short(f, 32)).slice(0, 40) : [],
50
+ output_fmt: pick(p.output_fmt, FMTS, "json"),
51
+ repo_size: pick(p.repo_size, SIZES, "unknown"),
52
+ duration: pick(p.duration, DURATIONS, "unknown"),
53
+ success: typeof p.success === "boolean" ? p.success : null,
54
+ error_kind: short(p.error_kind, 64),
55
+ feature: short(p.feature, 32),
56
+ install_id: uuid(p.install),
57
+ session: short(p.session, 16),
58
+ };
59
+
60
+ try {
61
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
62
+ await supabase.from("telemetry_events").insert(row);
63
+ } catch (e) {
64
+ console.error("telemetry insert failed", e);
65
+ // still return 200 — never penalize the CLI for our DB hiccup
66
+ }
67
+
68
+ return new Response(JSON.stringify({ received: true }), {
69
+ status: 200,
70
+ headers: { "Content-Type": "application/json" },
71
+ });
72
+ });
@@ -0,0 +1,36 @@
1
+ -- Anonymous, opt-in usage telemetry. No PII, no paths — every column is a
2
+ -- bounded categorical or bucket. Populated by the `telemetry` edge function.
3
+ create table if not exists public.telemetry_events (
4
+ id uuid primary key default gen_random_uuid(),
5
+ received_at timestamptz not null default now(),
6
+ event text not null,
7
+ client_ts text,
8
+ v text, -- sourcecode version
9
+ py text, -- python major.minor
10
+ os text, -- linux | macos | windows | other
11
+ arch text, -- x64 | arm64 | other
12
+ cmd text, -- analyze | prepare-context | telemetry | unknown
13
+ flags jsonb default '[]'::jsonb,
14
+ output_fmt text, -- json | yaml
15
+ repo_size text, -- tiny | small | medium | large | huge | unknown
16
+ duration text, -- <1s | <5s | <15s | <60s | 60s+ | unknown
17
+ success boolean,
18
+ error_kind text, -- exception class name only
19
+ feature text, -- gated feature / task name (closed set)
20
+ install_id uuid, -- stable anonymous install id (random, no PII)
21
+ session text -- ephemeral 8-char hex, NOT a stable user id
22
+ );
23
+
24
+ -- If the table already exists from an earlier deploy, add the column:
25
+ alter table public.telemetry_events add column if not exists install_id uuid;
26
+
27
+ -- Common query axes: funnel by event, adoption by version, time series,
28
+ -- and unique-install / conversion / retention by install_id.
29
+ create index if not exists telemetry_events_event_idx on public.telemetry_events (event);
30
+ create index if not exists telemetry_events_received_at_idx on public.telemetry_events (received_at);
31
+ create index if not exists telemetry_events_feature_idx on public.telemetry_events (feature);
32
+ create index if not exists telemetry_events_install_idx on public.telemetry_events (install_id);
33
+
34
+ -- RLS on, no policies: only the service role (edge function) can write/read.
35
+ -- The public anon/publishable key cannot touch this table.
36
+ alter table public.telemetry_events enable row level security;
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes