Compare commits
213
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c | ||
|
|
d52ceeb0f9 | ||
|
|
a9f146d77e | ||
|
|
04e80a3531 | ||
|
|
5569d08b59 | ||
|
|
c628997934 | ||
|
|
8353761b6e | ||
|
|
15f058f760 | ||
|
|
8dfd81ebe4 | ||
|
|
bef5542ce3 | ||
|
|
57463f993b | ||
|
|
c7cd527045 | ||
|
|
00235d1594 | ||
|
|
6d177d5d8a | ||
|
|
89b08349c4 | ||
|
|
f1d61ebd6e | ||
|
|
dbf60d3a7e | ||
|
|
b0c1e2f826 | ||
|
|
0eb2d60569 | ||
|
|
3b305b0f8e | ||
|
|
a451d4187f | ||
|
|
8a692bfe2f | ||
|
|
cde01ff02b | ||
|
|
d250f41749 | ||
|
|
4fb05748c3 | ||
|
|
10d1455eff | ||
|
|
e6a222f196 | ||
|
|
1fdc6720a0 | ||
|
|
080379cc64 | ||
|
|
8c9cf370e8 | ||
|
|
c7588c5aa2 | ||
|
|
9b81fcf2fc | ||
|
|
242941740b | ||
|
|
7e51fe6345 | ||
|
|
8c6fceb682 | ||
|
|
0dadabfea6 | ||
|
|
261578357a | ||
|
|
447016f03b | ||
|
|
e13722eae9 | ||
|
|
2b30dd4708 | ||
|
|
440bf0cd56 | ||
|
|
8add483a5b | ||
|
|
3258a24f20 | ||
|
|
82b6c6b333 | ||
|
|
08b00627e5 | ||
|
|
fc65914f55 | ||
|
|
8e23f31685 | ||
|
|
f5aaa1cccb | ||
|
|
b4878b7795 | ||
|
|
9cc63b7854 | ||
|
|
e7b5082398 | ||
|
|
446403cdf2 | ||
|
|
a7ae9ed3a4 | ||
|
|
ed289b9ecd | ||
|
|
b92a167b5a | ||
|
|
a06c5ed281 | ||
|
|
10915d04fc | ||
|
|
a459579f8b | ||
|
|
a6799b91d3 | ||
|
|
c44fd8495e | ||
|
|
7ef7eef963 | ||
|
|
8cd0f1dc88 | ||
|
|
b26d382c11 | ||
|
|
9e2f83a85e | ||
|
|
a1d2599c47 | ||
|
|
ca1eee5425 | ||
|
|
6bc5e8f006 | ||
|
|
ea9f17e8d2 | ||
|
|
7e65df1d21 | ||
|
|
f149d02e9a | ||
|
|
da669db1eb | ||
|
|
7c1f08d943 | ||
|
|
f687322c3f | ||
|
|
752b300474 | ||
|
|
3d8cb22235 | ||
|
|
fb6bde279e | ||
|
|
5c709c1a12 | ||
|
|
78d41daf4f | ||
|
|
8047e58ae0 | ||
|
|
d8f334007b | ||
|
|
2b8ba340a6 | ||
|
|
ee9f3f1b8c | ||
|
|
847972b582 | ||
|
|
93b61d422a | ||
|
|
fe0c938358 | ||
|
|
0addafa76f | ||
|
|
94673090f1 | ||
|
|
50a712c276 | ||
|
|
77f587b53b | ||
|
|
ee21278e82 | ||
|
|
d5035cbf55 | ||
|
|
a8bde94153 | ||
|
|
41ed3a1f0f | ||
|
|
208dd5f2f6 | ||
|
|
d4a02fb606 | ||
|
|
90f16403fb | ||
|
|
0c136f8f53 | ||
|
|
73902cc0a4 | ||
|
|
a7d0f5af5b | ||
|
|
2a1a0839c0 | ||
|
|
107407b854 | ||
|
|
366928b61f | ||
|
|
84c4c0fc25 | ||
|
|
a5e627a57b | ||
|
|
46f5c28237 | ||
|
|
1e8168d413 | ||
|
|
61c6aaaaed | ||
|
|
033c45df4d | ||
|
|
2345a45f14 | ||
|
|
ce1c7cfd4b | ||
|
|
e9755d364c | ||
|
|
f4398fce25 | ||
|
|
ede02b7575 | ||
|
|
c089d9e8ba | ||
|
|
bb4fb624f5 | ||
|
|
37a04e5103 | ||
|
|
383643392a | ||
|
|
fabee4df1d | ||
|
|
c8cd8c9720 | ||
|
|
6e05f5134f | ||
|
|
1685d0b57a | ||
|
|
608f2eda2f | ||
|
|
78ae3fbbd7 | ||
|
|
5a534547ae | ||
|
|
26bacbf700 | ||
|
|
9b28a86f7c | ||
|
|
5c49294ad1 | ||
|
|
1ff650ecd5 | ||
|
|
e7863ab950 | ||
|
|
18c6205e06 | ||
|
|
230ea1d156 | ||
|
|
38e0185325 | ||
|
|
7433391c30 | ||
|
|
822180bffc | ||
|
|
7237e8ff39 | ||
|
|
952f7b43d3 | ||
|
|
438076c572 | ||
|
|
fe1ac5434c | ||
|
|
9aef361bf0 | ||
|
|
f70430e1bf | ||
|
|
72a9864e94 | ||
|
|
2db2086c10 | ||
|
|
ac4a4a96e8 | ||
|
|
6286c18a72 | ||
|
|
70b4123452 | ||
|
|
0c2a6c5330 | ||
|
|
5c9ffb84e8 | ||
|
|
988eb6d575 | ||
|
|
0710d143a9 | ||
|
|
9c173df753 | ||
|
|
a07c8104d5 | ||
|
|
224bb93beb | ||
|
|
923d1fe762 | ||
|
|
4c0ea66126 | ||
|
|
b6c0f40f66 | ||
|
|
7e93229eee | ||
|
|
4470139504 | ||
|
|
cce6525f09 | ||
|
|
c7445cfab1 | ||
|
|
ff45b2379c | ||
|
|
d3b117e470 | ||
|
|
64771fffc6 | ||
|
|
395cee3c9c | ||
|
|
5e4ad307c9 | ||
|
|
c52e4c4e27 | ||
|
|
d63beae04c | ||
|
|
c314a08f6d | ||
|
|
d2d62b6799 | ||
|
|
f6036f21ec | ||
|
|
1a93c7657a | ||
|
|
85223baa9d | ||
|
|
0a04025fe9 | ||
|
|
d813dee282 | ||
|
|
13e5c3bd23 | ||
|
|
215af4bbc4 | ||
|
|
127e7c76e9 | ||
|
|
aa1c49542b | ||
|
|
1036eb860d | ||
|
|
f325c0485c | ||
|
|
5a6aaad885 | ||
|
|
e4f963d170 | ||
|
|
f424b3a9f7 | ||
|
|
990c2bbd4c | ||
|
|
764f75085e | ||
|
|
9e17c2fadd | ||
|
|
53b2252997 | ||
|
|
718ef09dfa | ||
|
|
3f52e9cc54 | ||
|
|
255afde48e | ||
|
|
da51517ffc | ||
|
|
ea29f053e3 | ||
|
|
e1e8dd5415 | ||
|
|
c06b33e83b | ||
|
|
9ca124a868 | ||
|
|
af563603f9 | ||
|
|
fee3d0b55a | ||
|
|
baaffc66db | ||
|
|
7d16bdba75 | ||
|
|
7b4bfe8b27 | ||
|
|
32782227f4 | ||
|
|
57b36b5a09 | ||
|
|
19e1bb0790 | ||
|
|
7a2b26ec89 | ||
|
|
6dc63d9250 |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"vercel@claude-plugins-official": true,
|
||||
"supabase@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
name: apple-design
|
||||
description: Apple's approach to interface design and fluid, physical motion, translated for the web. Use when building or reviewing gesture-driven UI, spring animations, drag/swipe/sheet interactions, momentum and interruptible transitions, translucent materials and depth, typography (optical sizing, tracking, leading), reduced-motion, or the design foundations (feedback, spatial consistency, restraint) behind Apple-style interfaces.
|
||||
---
|
||||
|
||||
# Apple Design
|
||||
|
||||
How Apple builds interfaces that stop feeling like a computer and start feeling like an extension of you. This knowledge comes from Apple's WWDC design talks — chiefly _Designing Fluid Interfaces_ (WWDC 2018) — distilled and translated into the web platform (CSS, Pointer Events, `requestAnimationFrame`, spring libraries like Motion/Framer Motion).
|
||||
|
||||
The through-line: **an interface feels alive when motion starts from the current on-screen value, inherits the user's velocity, projects momentum forward, and can be grabbed and reversed at any instant.** Springs are the tool that makes all of this natural, because they are inherently interruptible and velocity-aware.
|
||||
|
||||
## The Core Idea
|
||||
|
||||
> "When we align the interface to the way we think and move, something magical happens — it stops feeling like a computer and starts feeling like a seamless extension of us."
|
||||
|
||||
An interface is fluid when it behaves like the physical world: things respond instantly, move continuously, carry momentum, resist at boundaries, and can be redirected mid-motion. Everything below is a way to get closer to that.
|
||||
|
||||
Apple frames design as serving four human needs: **safety/predictability, understanding, achievement, and joy.** Every rule here serves one of them.
|
||||
|
||||
## 1. Response — kill latency
|
||||
|
||||
The moment lag appears, the feeling of directness "falls off a cliff." Response is the foundation everything else is built on.
|
||||
|
||||
- **Respond on pointer-down, not on release.** Highlight a button the instant it's pressed. Waiting for `click`/touch-up to show feedback feels dead.
|
||||
- **Be vigilant about every latency.** Audit debounces, artificial timers, transition waits, and the ~300ms tap delay. Anything on the input path that isn't essential is a regression.
|
||||
- **Feedback must be continuous _during_ the interaction, not just at the end.** For a drag, slider, or drawer, update the UI 1:1 with the pointer the whole way through — never animate only when the gesture completes.
|
||||
|
||||
```css
|
||||
/* Feedback lives on the press, and it's instant */
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
transition: transform 100ms ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Direct manipulation — 1:1 tracking
|
||||
|
||||
> "Touch and content should move together."
|
||||
|
||||
When the user drags something, it must stay glued to the finger — and respect the offset from _where they grabbed it_. Snapping to the element's center on grab breaks the illusion immediately.
|
||||
|
||||
- Use Pointer Events with `setPointerCapture` so tracking continues even when the pointer leaves the element's bounds.
|
||||
- Track a short **velocity/position history** (last few `pointermove` events), not just the current point — you'll need velocity at release.
|
||||
|
||||
```js
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const grabOffset = e.clientY - el.getBoundingClientRect().top; // respect where they grabbed
|
||||
// ...track position + timestamp history for velocity
|
||||
});
|
||||
```
|
||||
|
||||
## 3. Interruptibility — the single most important principle
|
||||
|
||||
> "The thought and the gesture happen in parallel."
|
||||
|
||||
Every animation must be interruptible and redirectable at any moment. A user must be able to grab a moving element mid-flight and reverse it without waiting for the animation to finish. A closing modal the user grabs again should follow the finger — not finish closing first, then reopen.
|
||||
|
||||
- **Never lock out input during a transition.**
|
||||
- **Always animate from the _presentation_ (current) value, never the target value.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the logical/target value causes a visible jump.
|
||||
- **Avoid CSS transitions and `@keyframes` for anything gesture-driven** — they can't be smoothly grabbed and reversed mid-flight. Springs animate from the current value by default, which is exactly what interruption needs.
|
||||
- **When a gesture reverses, blend velocity — don't hard-cut it.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall." Spring libraries that carry velocity through a re-target avoid it. (This is what iOS's _additive animations_ do natively; on the web, choose a spring library that re-targets from the current velocity.)
|
||||
- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities.
|
||||
|
||||
## 4. Behavior over animation — use springs
|
||||
|
||||
> "Think of animation as a conversation between you and the object, not something prescribed by the interface."
|
||||
|
||||
A pre-scripted, fixed-duration animation can't respond to new input. A spring can — new input just changes the target, and the motion stays continuous. Reach for springs for anything a user can touch.
|
||||
|
||||
Apple deliberately replaced the physics triplet (mass/stiffness/damping) with two designer-friendly parameters. Think in these:
|
||||
|
||||
- **Damping ratio** — controls overshoot. `1.0` = critically damped, no bounce, smooth settle. `< 1.0` = overshoots and oscillates. Lower = bouncier.
|
||||
- **Response** — how quickly the value reaches the target, in seconds. Lower = snappier. **This is not "duration"** — a spring has no fixed duration; its settle time emerges from the parameters.
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- Start most UI at **damping `1.0`** (critically damped) — graceful and non-distracting.
|
||||
- Add bounce (**damping ~`0.8`**) **only when the gesture itself carried momentum** (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right.
|
||||
|
||||
**Concrete values Apple ships:**
|
||||
|
||||
| Interaction | Damping | Response |
|
||||
| ---------------------------- | ------- | -------- |
|
||||
| Move / reposition (e.g. PiP) | `1.0` | `0.4` |
|
||||
| Rotation | `0.8` | `0.4` |
|
||||
| Drawer / sheet | `0.8` | `0.3` |
|
||||
|
||||
**Web mapping (Motion / Framer Motion):** the `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is `damping: 1.0` springs everywhere by default; reserve bounce for momentum-driven, physical interactions.
|
||||
|
||||
```js
|
||||
import { animate } from "motion";
|
||||
|
||||
// Critically damped default (no overshoot)
|
||||
animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 });
|
||||
|
||||
// Momentum interaction — a little bounce, only because a flick preceded it
|
||||
animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 });
|
||||
```
|
||||
|
||||
## 5. Velocity handoff — the seam between drag and animation
|
||||
|
||||
When a gesture ends, the animation must **continue at the finger's exact velocity**, so there's no visible seam between dragging and animating. This is the detail that most separates "fluid" from "fine."
|
||||
|
||||
Pass the pointer's release velocity as the spring's initial velocity. Some spring APIs want **relative** velocity — normalize it by the remaining distance to the target:
|
||||
|
||||
```
|
||||
relativeVelocity = gestureVelocity / (targetValue − currentValue)
|
||||
```
|
||||
|
||||
Example: element at `y=50`, target `y=150` (100px to go), finger moving 50px/s → initial spring velocity = `50 / 100 = 0.5`. Framer Motion / Motion take absolute px/s velocity directly (`velocity` option), so you usually hand it the raw value.
|
||||
|
||||
## 6. Momentum projection — animate to where the gesture is _going_
|
||||
|
||||
> "Take a small input and make a big output."
|
||||
|
||||
Don't snap to the nearest boundary from the _release point_. Use velocity to **project the resting position** — exactly like scroll deceleration — then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element.
|
||||
|
||||
Apple's exact projection function (from the _Designing Fluid Interfaces_ sample code):
|
||||
|
||||
```js
|
||||
// decelerationRate ≈ 0.998 for normal scroll feel; 0.99 for snappier
|
||||
function project(initialVelocity /* px/s */, decelerationRate = 0.998) {
|
||||
return ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate);
|
||||
}
|
||||
|
||||
const projectedEndpoint = currentPosition + project(releaseVelocity);
|
||||
const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection
|
||||
animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (§5)
|
||||
```
|
||||
|
||||
Note: the physics-textbook `v²/(2·decel)` is _not_ what Apple ships — use the exponential-decay form above. This is the standard behavior in good bottom-sheets and carousels (Vaul, Embla).
|
||||
|
||||
## 7. Spatial consistency — symmetric paths, anchored origins
|
||||
|
||||
> "If something disappears one way, we expect it to emerge from where it came."
|
||||
|
||||
- **Enter and exit along the same path.** A panel that slides in from the right must dismiss to the right. In-from-right / out-the-bottom feels disconnected and confusing.
|
||||
- **Anchor interactions to their source.** A menu, popover, or sheet should originate from the element that triggered it — set `transform-origin` to the trigger, so the spatial relationship between button and content is obvious. (This is the same origin-awareness point as popovers scaling from their trigger, not their center.)
|
||||
- **Mirror the easing on reversible transitions** so the outbound path matches the return path (use inverse cubic-bézier control points for the two directions).
|
||||
|
||||
## 8. Hint in the direction of the gesture
|
||||
|
||||
Humans predict a final state from a trajectory. Intermediate motion should telegraph where things are going — Control Center modules "grow up and out toward your finger." Make the in-between frames point at the outcome, not just interpolate blindly to it.
|
||||
|
||||
## 9. Rubber-banding — soft boundaries
|
||||
|
||||
At an edge, resist progressively instead of stopping hard. A hard stop reads as "frozen"; continuous resistance reads as "responsive, but there's nothing more here." Apply damping that increases the further past the boundary the user drags.
|
||||
|
||||
```js
|
||||
// The further past the bound, the less the element follows — real things slow before they stop
|
||||
function rubberband(overshoot, dimension, constant = 0.55) {
|
||||
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Gesture design details (the "feel" checklist)
|
||||
|
||||
- **Tap:** highlight on touch-_down_ (instant), commit on touch-_up_. Add ~10px of hysteresis/hit padding around the target, and allow cancel-by-dragging-away and back.
|
||||
- **Drag/swipe:** require a small movement threshold (hysteresis, ~10px) before committing to a direction, then track 1:1.
|
||||
- **Detect all plausible gestures in parallel from the first move**, then confidently cancel the losers once intent is clear. Avoid recognizers that only report a _final_ state (`swipeleft`-type events) — they throw away the continuous tracking you need for feedback.
|
||||
- **Minimize disambiguation delays.** Double-tap detection unavoidably delays single taps; only pay that cost where double-tap truly exists.
|
||||
|
||||
## 11. Frame-level smoothness
|
||||
|
||||
Smoothness is about _what's in the frames_, not just the frame rate.
|
||||
|
||||
- Keep the per-frame positional change below the perception threshold to avoid strobing.
|
||||
- For very fast motion, a subtle **motion blur / stretch** encodes speed and reads better than a hard sharp streak.
|
||||
- `requestAnimationFrame` is the web's display-synced clock (Apple uses `CADisplayLink`). Animate only compositor-friendly properties — `transform` and `opacity` — and hint with `will-change` where motion is imminent.
|
||||
|
||||
## 12. Materials & depth — translucency conveys hierarchy
|
||||
|
||||
Apple uses translucent materials as a floating functional layer that brings structure without stealing focus. On the web, approximate with `backdrop-filter`.
|
||||
|
||||
- **Build nav/toolbars/sheets as translucent layers** (`backdrop-filter: blur()` + a semi-transparent background) with content scrolling underneath — not opaque bars that consume a fixed strip.
|
||||
- **Material weight encodes hierarchy:** darker/heavier materials separate structural regions (sidebars); lighter materials draw attention to interactive elements (buttons). **Never stack a light translucent surface on another** — legibility collapses.
|
||||
- **Bigger surfaces should read as thicker:** stronger blur + a deeper shadow than small chips. Consider context-aware shadow — heavier over busy/text content for separation, lighter over plain backgrounds.
|
||||
- **Dim to focus, separate to keep flow.** A modal task pairs the surface with a dimming scrim and pushes the background back/down. A parallel, non-blocking panel uses translucency and offset _without_ a scrim so the flow isn't broken. For stacked sheets, progressively dim and push back each parent layer.
|
||||
- **Vibrancy keeps text legible over changing backgrounds.** Over blurred/translucent surfaces, don't use flat gray text — use higher-contrast, slightly heavier weight, and a small letter-spacing bump. Put color on a solid layer, not the translucent foreground.
|
||||
- **Scroll edge effects, not hard dividers.** Instead of a 1px border under a sticky header, fade a small blur/gradient mask where content meets floating chrome — only where floating UI actually overlaps content.
|
||||
- **Materialize, don't just fade.** For glass/blur surfaces, animate blur radius and scale together on enter/exit, so the surface reads as a real material arriving rather than a plain opacity fade.
|
||||
|
||||
```css
|
||||
.toolbar {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.4); /* bright top edge = light catching the material */
|
||||
}
|
||||
```
|
||||
|
||||
## 13. Multimodal feedback — motion + sound + haptics
|
||||
|
||||
Three rules for combining senses (from _Designing Audio-Haptic Experiences_):
|
||||
|
||||
1. **Causality** — it must be obvious what caused the feedback. Trigger it on the actual causal event (the toggle flipping, the item snapping home), and match its character to the action's physicality.
|
||||
2. **Harmony** — the visual, the sound, and the haptic must fire on the **same frame**. Latency between them destroys the illusion. Don't let a CSS transition lag the audio/haptic (Vibration API).
|
||||
3. **Utility** — add feedback only where it earns its place. Reserve haptics/sound for meaningful moments (success, error, commit, snap). Over-feedback trains users to ignore all of it.
|
||||
|
||||
## 14. Reduced motion & accessibility
|
||||
|
||||
Reduced motion doesn't mean _no_ feedback — it means a gentler, non-vestibular equivalent. Respond to three independent signals and bake them into your components:
|
||||
|
||||
- **`prefers-reduced-motion: reduce`** — replace slides/springs/parallax with short opacity **cross-fades or static transitions**. Drop elastic/overshoot. Keep opacity/color changes that aid comprehension.
|
||||
- **`prefers-reduced-transparency: reduce`** — make translucent surfaces frostier/solid: raise background opacity, drop the blur.
|
||||
- **`prefers-contrast: more`** — near-solid backgrounds with a defined, contrasting border.
|
||||
|
||||
Also: avoid full-viewport moving backgrounds, slow looping oscillations (near 0.2 Hz / one cycle per 5s), and abrupt brightness jumps (ease dark↔light theme changes). Make large moving objects semi-transparent while they travel, and fade big surfaces out during a large reposition and back in once settled.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sheet {
|
||||
transition: opacity 200ms ease;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.toolbar {
|
||||
background: white;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 15. Typography — optical sizing, tracking, leading
|
||||
|
||||
Apple designs type to change shape with size; the same discipline applies on the web. (From _The Details of UI Typography_, WWDC 2020.)
|
||||
|
||||
- **Tracking (letter-spacing) is size-specific — never one value for all sizes.** Large display text wants _negative_ tracking (letters read too far apart as they grow); small text wants slightly _positive_ tracking for legibility. A fixed `letter-spacing` is wrong somewhere. Tighten headings, leave body near `0`.
|
||||
- **Leading (line-height) tracks size inversely.** Tight on large headings, looser on body copy. Increase it for scripts with tall ascenders/descenders; tighten it for dense, information-heavy UI.
|
||||
- **Build hierarchy from weight + size + leading as a set,** not size alone. Emphasize with weight — it adds presence without taking more space.
|
||||
- **Respect the user's text-size setting** (Dynamic Type). Scale layout _with_ the text — spacing in `rem`/`em`, not fixed px — so a larger font doesn't break the layout.
|
||||
- **Default to the platform's system font** before a custom face; it already ships optical sizing, tracking tables, and legibility tuning. Override only with a reason.
|
||||
|
||||
```css
|
||||
:root {
|
||||
font:
|
||||
100%/1.5 system-ui,
|
||||
sans-serif;
|
||||
} /* body: system font, comfortable leading */
|
||||
|
||||
.display {
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
line-height: 1.05; /* tight leading for large text */
|
||||
letter-spacing: -0.02em; /* negative tracking as it grows */
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## 16. Design foundations — the eight principles
|
||||
|
||||
The motion and craft above serve Apple's eight design principles (_Principles of Great Design_, WWDC 2026). Use these as the names you reason with:
|
||||
|
||||
1. **Purpose.** Make with intention; decide what _not_ to build. Every feature asks for the user's time, attention, and trust — spend that budget only where it pays off.
|
||||
2. **Agency.** Keep people in control: offer choices, don't force a single path. Back it with forgiveness — easy undo for slips, a confirmation dialog only for genuinely destructive, irreversible actions (use sparingly; overusing it trains people to click through).
|
||||
3. **Responsibility.** Act in the user's interest. Privacy: ask at the right moment, only for what's needed, transparently. Safety: anticipate misuse and harm — especially with AI (an allergy-aware recipe app must not suggest a harmful ingredient). Add previews, confirmations, disclaimers; cut a feature whose risk outweighs its value.
|
||||
4. **Familiarity.** Build on what people already know. Use metaphors that are neither too literal nor too abstract (a trash can means delete), and honor their physics. Be consistent: things that look the same must behave the same and live in the same place (close is always top-left on macOS) so people can predict what happens next. Only break a familiar pattern if you can prove it's better — then test it, don't assume.
|
||||
5. **Flexibility.** Design for different contexts, devices, and the full range of abilities. Adapt to the platform (iPhone = quick touch; desktop = deep workflows with precise pointer control) and to the situation. Design inclusively (age, language, expertise, accessibility). When no single layout fits everyone, let people personalize — rearrange controls, hide what they don't use.
|
||||
6. **Simplicity — not minimalism.** Strip the unnecessary so the core purpose shines; burying everything in one place looks minimal but isn't simple. Be concise (plain language, no jargon, fewer steps) and clear (use hierarchy — order, spacing, contrast — so the most important thing is the most obvious). Every element earns its place; sometimes _adding_ context simplifies (a video scrubber that shows time remaining). Show the common path first, advanced options one level deeper.
|
||||
7. **Craft.** Uncompromising attention to detail builds trust. Beautiful typography, colors that adapt to light/dark, clear iconography, and responsive animations that give immediate, natural feedback. Nothing is random — every spacing, timing, and alignment value is a deliberate choice you can defend. Jittery scroll, misaligned icons, and layouts that break on rotation read as carelessness. Craft needs iteration and longevity — keep evolving the design as features and hardware change.
|
||||
8. **Delight.** The result of getting the other seven right, not confetti tacked on top. Decide the emotion you want people to feel (calm, confident, excited) and reinforce it in every decision.
|
||||
|
||||
Tactical rules that serve these:
|
||||
|
||||
- **Feedback comes in four kinds:** status, completion, warning, error. Confirm meaningful actions, expose ongoing status, warn before problems, validate inline (not on submit).
|
||||
- **Wayfinding.** Every screen should answer: Where am I? Where can I go? What's there? How do I get out? Never trap the user.
|
||||
- **Grouping & mapping.** Proximity implies relationship; place a control near what it affects and arrange controls to mirror what they change. If you need a label to explain a control, the mapping is weak.
|
||||
- **Direct, specific labels beat safe generic ones.** Name nav items for their contents ("Progress", "Library"), not vague umbrellas ("Home"). Specificity creates predictability.
|
||||
|
||||
## 17. Process
|
||||
|
||||
- **Prototype interactively — an interactive demo is worth "a million static designs."** You discover the interface by building and playing with it; a working prototype also sets a concrete bar that prevents a mediocre final implementation.
|
||||
- **Design interaction and visuals together.** "You shouldn't be able to tell where one ends and the other begins." Motion is not a layer added after the pixels.
|
||||
- **Test with real people in real context**, and review motion with fresh eyes — play it in slow motion / frame-by-frame to catch what's invisible at full speed.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Need | Technique | Concrete value |
|
||||
| --------------------------- | ------------------------------------ | ---------------------------------------------------- |
|
||||
| Default UI spring | Critically damped, no overshoot | `damping 1.0`, `response 0.3–0.4` |
|
||||
| Momentum / flick spring | Under-damped, slight bounce | `damping ~0.8`, `response 0.3–0.4` |
|
||||
| Gesture → spring velocity | Hand off release velocity | `gestureVelocity / (target − current)` if normalized |
|
||||
| Flick landing point | Project momentum | `current + (v/1000)·d/(1−d)`, `d ≈ 0.998` |
|
||||
| Interrupt cleanly | Start from presentation (live) value | read the on-screen transform |
|
||||
| Avoid reversal "brick wall" | Carry velocity through re-target | spring that blends velocity |
|
||||
| Reversible transition | Mirror the easing curve | inverse cubic-bézier |
|
||||
| Decide reverse vs. commit | Use velocity **sign**, not position | at release |
|
||||
| 1:1 drag | Pointer Events + capture | respect the grab offset |
|
||||
| Feedback | On pointer-down, continuous | never only at the end |
|
||||
| Boundary | Rubber-band, don't hard-stop | progressive resistance |
|
||||
| Translucent chrome | `backdrop-filter` layer | content scrolls under |
|
||||
| Type tracking | Size-specific, never fixed | tighten large text (`-0.02em`), body near `0` |
|
||||
| Reduced motion | Cross-fade, not slide/spring | `@media (prefers-reduced-motion)` |
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Regole di progetto CrAPP
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolanti e valgono
|
||||
per intero.
|
||||
|
||||
- Non implementare funzionalità non documentate in `docs/`.
|
||||
- Lavora su `develop`, mai direttamente su `main`.
|
||||
- Codice, commenti e documentazione in italiano.
|
||||
|
||||
Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche
|
||||
Claude Code e Codex. Vale per qualsiasi aggiunta o modifica — regola, funzionalità, decisione,
|
||||
schema database: prima di considerare finito il lavoro esegui la checklist «Fine lavoro» di
|
||||
`AGENTS.md`.
|
||||
@@ -1,15 +0,0 @@
|
||||
# TEMPLATE DI PRODUZIONE per l'app CrAPP — copia in .env sul server e compila.
|
||||
# I valori Supabase vengono dal .env dello stack self-hosted in infra/supabase/docker/
|
||||
# (ANON_KEY -> VITE_SUPABASE_PUBLISHABLE_KEY, SERVICE_ROLE_KEY -> SUPABASE_SERVICE_ROLE_KEY).
|
||||
|
||||
VITE_SUPABASE_URL=https://tuodominio.it/api
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Notifiche push (genera con: npx web-push generate-vapid-keys)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:tuamail@esempio.it
|
||||
|
||||
# Porta su cui ascolta il server Node generato da Nitro (preset node-server)
|
||||
PORT=3000
|
||||
@@ -0,0 +1,70 @@
|
||||
name: 🐛 Segnala un bug
|
||||
description: Segnala un comportamento non corretto di CrAPP
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: input
|
||||
id: titolo-bug
|
||||
attributes:
|
||||
label: Titolo del bug
|
||||
description: Riassumi il problema in una frase
|
||||
placeholder: "Es. Il numero di maglia duplicato non blocca il salvataggio"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: comportamento-attuale
|
||||
attributes:
|
||||
label: Cosa succede
|
||||
description: Descrivi il comportamento che hai osservato
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: comportamento-atteso
|
||||
attributes:
|
||||
label: Cosa ti aspettavi succedesse
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: passaggi
|
||||
attributes:
|
||||
label: Passaggi per riprodurre
|
||||
placeholder: |
|
||||
1. Vai su ...
|
||||
2. Clicca su ...
|
||||
3. Compare l'errore ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: ambiente
|
||||
attributes:
|
||||
label: Dispositivo/browser
|
||||
description: Da dove hai riscontrato il problema
|
||||
options:
|
||||
- Smartphone (Chrome/Safari)
|
||||
- Computer (Chrome)
|
||||
- Computer (Safari)
|
||||
- Computer (Firefox)
|
||||
- Altro
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot
|
||||
description: Trascina qui una o più immagini che mostrano il problema (opzionale)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: duplicati
|
||||
attributes:
|
||||
label: Verifica
|
||||
options:
|
||||
- label: Ho controllato che non esista già una issue aperta su questo stesso problema
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,55 @@
|
||||
name: ✨ Richiesta di funzionalità
|
||||
description: Suggerisci una nuova funzionalità per CrAPP
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: input
|
||||
id: titolo-feature
|
||||
attributes:
|
||||
label: Titolo della funzionalità
|
||||
description: Riassumi la proposta in una frase
|
||||
placeholder: "Es. Notifica push quando viene aggiunto un nuovo evento"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problema
|
||||
attributes:
|
||||
label: Problema che risolve
|
||||
description: Cosa non riesci a fare oggi, o cosa ti costa troppa fatica
|
||||
placeholder: "Es. Non mi accorgo quando viene aggiunta una partita finché non apro l'app"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: soluzione
|
||||
attributes:
|
||||
label: Soluzione proposta
|
||||
description: Come immagini che dovrebbe funzionare
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: Alternative considerate
|
||||
description: Altri modi in cui potresti risolvere lo stesso problema (opzionale)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot o mockup
|
||||
description: Trascina qui immagini di riferimento (opzionale)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: duplicati
|
||||
attributes:
|
||||
label: Verifica
|
||||
options:
|
||||
- label: Ho controllato che non esista già una richiesta simile
|
||||
required: true
|
||||
+2
-4
@@ -39,7 +39,5 @@ dist-ssr
|
||||
|
||||
# Optional
|
||||
.vercel
|
||||
|
||||
# Supabase self-host: dati runtime, mai versionati
|
||||
infra/supabase/docker/volumes/db/data/
|
||||
infra/supabase/docker/volumes/storage/
|
||||
# Supabase CLI local state
|
||||
supabase/.temp/
|
||||
@@ -1,31 +0,0 @@
|
||||
# Obiettivi di squadra: sezione in Squadra + widget in home
|
||||
|
||||
## Stato attuale
|
||||
|
||||
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
|
||||
|
||||
## Cosa faccio
|
||||
|
||||
1. **Modello dati locale** in `src/lib/crapp-data.ts`
|
||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
||||
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
|
||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
||||
3. **Widget home dinamico** (`src/routes/index.tsx`)
|
||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
||||
4. **Obiettivi demo iniziali**
|
||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
||||
- 70% di risposte entro 24h nel prossimo mese.
|
||||
- Prima vittoria del campionato (collegato allo storico match).
|
||||
- 5 vittorie in campionato (collegato allo storico match).
|
||||
- 10 vittorie in campionato (collegato allo storico match).
|
||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
|
||||
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
|
||||
- Bottom nav e rotte invariate.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
|
||||
|
||||
## Stato attuale
|
||||
|
||||
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
|
||||
|
||||
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
|
||||
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
|
||||
|
||||
## Cosa serve per renderla operativa
|
||||
|
||||
1. **Rosa e profili giocatori sul database**
|
||||
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
|
||||
- Collegare ogni riga all'utente autenticato corrispondente.
|
||||
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
|
||||
|
||||
2. **Autenticazione reale**
|
||||
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
|
||||
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
|
||||
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
|
||||
|
||||
3. **Calendario eventi persistente**
|
||||
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
|
||||
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
|
||||
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
|
||||
|
||||
4. **Statistiche e badge dinamici**
|
||||
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
|
||||
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
|
||||
|
||||
5. **Scout live collegato ai dati reali**
|
||||
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
|
||||
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
|
||||
|
||||
6. **Classifica CSI**
|
||||
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
|
||||
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
|
||||
|
||||
7. **Notifiche push definitive**
|
||||
- Verificare che i cron job inviino correttamente i promemoria palloni.
|
||||
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
|
||||
|
||||
8. **Ruoli e permessi**
|
||||
- Definire chi può creare/modificare eventi (capitano/admin).
|
||||
- Chi può fare scout (designato per partita).
|
||||
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
|
||||
|
||||
## Cosa resta salvato nel Cloud
|
||||
|
||||
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
|
||||
|
||||
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
|
||||
|
||||
## Decisioni da prendere insieme
|
||||
|
||||
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
|
||||
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
|
||||
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
|
||||
@@ -1,23 +0,0 @@
|
||||
# Ridimensionamento badge nella lista squadra
|
||||
|
||||
## Obiettivo
|
||||
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
|
||||
|
||||
## Modifiche previste
|
||||
|
||||
1. **Lista squadra (`src/routes/squadra.tsx`)**
|
||||
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
|
||||
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
|
||||
- Lasciare invariati avatar, nome, ruolo ed età.
|
||||
|
||||
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
|
||||
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
|
||||
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
|
||||
|
||||
3. **Verifica**
|
||||
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
|
||||
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
|
||||
|
||||
## Cosa non cambia
|
||||
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
|
||||
- Layout generale della pagina e bottom navigation.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Rimuovere placeholder "Livello 7" dal Profilo
|
||||
|
||||
## Obiettivo
|
||||
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
|
||||
|
||||
## Modifica
|
||||
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
|
||||
|
||||
## Verifica
|
||||
- Build senza errori.
|
||||
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Selezione giocatore al primo avvio
|
||||
|
||||
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
|
||||
|
||||
## Cosa cambia
|
||||
|
||||
1. **Nuovo store `src/lib/user-store.ts`**
|
||||
- Persiste in `localStorage` l'`id` del giocatore scelto.
|
||||
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
|
||||
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
|
||||
|
||||
2. **Nuova route `/benvenuto`**
|
||||
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
|
||||
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
|
||||
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
|
||||
- Non mostra la bottom navigation.
|
||||
|
||||
3. **Reindirizzamento condizionato in `__root.tsx`**
|
||||
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
|
||||
- Dopo la scelta, l'app funziona normalmente.
|
||||
|
||||
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
|
||||
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
|
||||
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
|
||||
|
||||
5. **Cambio utente dalle impostazioni**
|
||||
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
|
||||
|
||||
## Note tecniche
|
||||
|
||||
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
|
||||
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
|
||||
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
|
||||
- Nessun backend richiesto: resta un prototipo locale.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Turno palloni nel calendario + promemoria
|
||||
|
||||
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
|
||||
|
||||
## Cosa vedrà la squadra
|
||||
|
||||
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
|
||||
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
|
||||
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
|
||||
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
|
||||
- **Due promemoria per l'incaricato**:
|
||||
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
|
||||
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
|
||||
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
|
||||
|
||||
## Impostazione tecnica
|
||||
|
||||
**Backend (Lovable Cloud)**
|
||||
- Attivazione di Lovable Cloud.
|
||||
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
|
||||
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
|
||||
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
|
||||
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
|
||||
|
||||
**Frontend**
|
||||
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
|
||||
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
|
||||
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
|
||||
|
||||
**Notifiche push**
|
||||
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
|
||||
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
|
||||
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
|
||||
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
|
||||
|
||||
## Ordine di lavoro
|
||||
|
||||
1. Attivare Lovable Cloud e creare tabelle + policy.
|
||||
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
|
||||
3. Rotazione automatica suggerita + storico turni.
|
||||
4. Banner promemoria in-app.
|
||||
5. Notifiche push + job giornaliero.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"template": "tanstack_start_ts_current",
|
||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
||||
}
|
||||
@@ -1,10 +1,141 @@
|
||||
<!-- LOVABLE:BEGIN -->
|
||||
> [!IMPORTANT]
|
||||
> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting
|
||||
> published git history — force pushing, or rebasing/amending/squashing commits
|
||||
> that are already pushed — as it rewrites history on Lovable's side and the
|
||||
> user will likely lose their project history.
|
||||
>
|
||||
> Commits you push to the connected branch sync back to Lovable and show up in
|
||||
> the editor, so keep the branch in a working state.
|
||||
<!-- LOVABLE:END -->
|
||||
# CrAPP — regole per gli assistenti AI
|
||||
|
||||
Regole vincolanti per qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT) che lavora
|
||||
su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ripete.
|
||||
|
||||
CrAPP è una PWA per la gestione di una squadra di pallavolo. Deve ridurre il lavoro degli
|
||||
amministratori, aumentare il coinvolgimento dei giocatori, centralizzare le informazioni della
|
||||
squadra e usare l'AI solo quando porta un beneficio reale. Deve restare semplice, veloce e
|
||||
usabile dallo smartphone anche da chi non è pratico.
|
||||
|
||||
## Prima di modificare il codice
|
||||
|
||||
1. Leggi l'indice [docs/README.md](docs/README.md) e segui l'ordine di lettura che indica; poi
|
||||
il documento del modulo interessato in [docs/modules/](docs/modules/).
|
||||
2. Verifica lo stato attuale del repository: commit recenti, modifiche non committate, lavoro
|
||||
introdotto da altri collaboratori o da altri assistenti.
|
||||
3. Non presumere che il progetto sia come l'hai lasciato nell'ultima sessione: la fonte di
|
||||
verità è il repository, non la cronologia della conversazione.
|
||||
|
||||
Non implementare funzionalità non documentate: prima si documenta
|
||||
([DD-002](docs/DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)), poi si scrive il codice.
|
||||
|
||||
## Comandi
|
||||
|
||||
Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
|
||||
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
|
||||
`minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run test # suite di test (test/); npm run test:all per quella completa
|
||||
|
||||
npx supabase start # database locale in Docker (migration applicate + seed)
|
||||
npx supabase db reset # ricrea il database locale da zero
|
||||
npx supabase db push # applica le migration al progetto cloud
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
**Chi aggiunge o modifica una funzione scrive anche il test.** Non è opzionale e non si
|
||||
rimanda: una funzione nuova senza test non è finita, una funzione modificata il cui test non
|
||||
copre più il comportamento nuovo va aggiornata nello stesso lavoro.
|
||||
|
||||
- I test devono **risultare verdi**: non si consegna con test rossi, non si commenta un test
|
||||
che fallisce e non si indebolisce un'asserzione per farla passare. Se un test rosso segnala
|
||||
un comportamento voluto che è cambiato, si aggiorna il test spiegando perché.
|
||||
- La logica di dominio pura sta in `src/lib/` ed è quella da coprire in `test/unit/`: se una
|
||||
funzione è difficile da testare perché mischia calcolo e hook, separala (`*-core.ts`) come
|
||||
già fatto per palloni e pagelle.
|
||||
- Convenzioni, struttura delle cartelle e comandi in [test/README.md](test/README.md).
|
||||
- Se il comportamento cambia, cambia anche la documentazione: modulo in
|
||||
[docs/modules/](docs/modules/), più i file elencati in Tracciabilità.
|
||||
|
||||
## Fine lavoro
|
||||
|
||||
Prima di dire che hai finito:
|
||||
|
||||
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
|
||||
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
|
||||
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
|
||||
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
|
||||
|
||||
## Git
|
||||
|
||||
`main` è la versione in produzione: qualsiasi commit deve lasciare l'app funzionante.
|
||||
`develop` pubblica una preview Vercel, ma oggi è fermo indietro rispetto a `main` e non
|
||||
rappresenta lo stato attuale (DD-019). I branch `feature/…`, `fix/…`, `refactor/…` servono per
|
||||
lavori paralleli o rischiosi.
|
||||
|
||||
**È l'utente a decidere su quale branch va un commit.** L'assistente può consigliare un branch
|
||||
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch né
|
||||
apre PR di propria iniziativa. In assenza di indicazioni si lavora dove si trova il repository.
|
||||
|
||||
Non committare, non fare push e non aprire PR senza che l'utente lo abbia chiesto.
|
||||
|
||||
## Tracciabilità
|
||||
|
||||
Ogni modifica significativa deve lasciare una traccia leggibile senza la cronologia delle
|
||||
conversazioni: commit con messaggio descrittivo, più il documento giusto tra
|
||||
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
|
||||
[PROJECT_STATE.md](PROJECT_STATE.md) (stato generale del progetto),
|
||||
[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) (decisioni architetturali, voci `DD-XXX`),
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
|
||||
|
||||
Quali contenuti vanno in quale file, e le convenzioni di scrittura, stanno nelle regole di
|
||||
manutenzione di [docs/README.md](docs/README.md): ogni informazione ha una sola casa, non
|
||||
duplicarla altrove.
|
||||
|
||||
## Database
|
||||
|
||||
Il database è Supabase; lo schema documentato sta in [docs/DATABASE.md](docs/DATABASE.md),
|
||||
allineato alle migration in `supabase/migrations/`.
|
||||
|
||||
- Ogni modifica allo schema è una **nuova** migration: le migration già applicate sono storia
|
||||
e non si riscrivono.
|
||||
- Non eliminare tabelle esistenti, non modificare lo schema senza motivazione.
|
||||
- Ordine: progetta → documenta → crea la migration → testala in locale (`npx supabase db reset`)
|
||||
→ verifica l'assenza di regressioni → solo dopo applicala in produzione.
|
||||
- Preferisci strutture scalabili, evita duplicazione dei dati.
|
||||
|
||||
## Codice e interfaccia
|
||||
|
||||
L'architettura tecnica (stack, struttura delle cartelle, punti fermi da non rompere) sta in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): leggila prima di toccare routing, `vite.config.ts`,
|
||||
client Supabase o autenticazione.
|
||||
|
||||
Componenti piccoli, riutilizzabili, a responsabilità singola. Prima di crearne uno nuovo,
|
||||
verifica se esiste già in `src/components/`. L'interfaccia resta semplice, moderna, veloce,
|
||||
ottimizzata per smartphone: poche schermate, pochi click, stile coerente con l'esistente.
|
||||
|
||||
## Regola anti-regressione
|
||||
|
||||
Le nuove versioni aggiungono funzionalità. Non riscrivere moduli già funzionanti senza una
|
||||
motivazione esplicita, e non fare refactoring trasversali mentre sviluppi altro. Prima di
|
||||
modificare un modulo esistente verifica quali altre parti dell'app lo usano.
|
||||
|
||||
## L'AI non deve
|
||||
|
||||
- introdurre librerie senza necessità, né aggirare `minimumReleaseAge`;
|
||||
- modificare il database o il comportamento dell'app senza richiesta esplicita;
|
||||
- eliminare funzionalità esistenti;
|
||||
- sovrascrivere modifiche di altri collaboratori senza averne compreso lo scopo;
|
||||
- riscrivere migration già applicate;
|
||||
- committare, pushare o cambiare branch di propria iniziativa.
|
||||
|
||||
## L'AI deve
|
||||
|
||||
- spiegare le modifiche importanti e segnalare rischi, conflitti e possibili regressioni
|
||||
**prima** di toccare parti sensibili;
|
||||
- mantenere la compatibilità con il codice esistente e riutilizzare i componenti;
|
||||
- privilegiare la semplicità;
|
||||
- tenere aggiornata la documentazione quando serve.
|
||||
|
||||
## Filosofia
|
||||
|
||||
Prima di scrivere codice: questa modifica rende CrAPP più semplice? Riduce il lavoro degli
|
||||
amministratori? Migliora l'esperienza dei giocatori? È coerente con la documentazione? Riduce
|
||||
o aumenta la complessità futura? Se almeno una risposta è negativa, rivaluta la soluzione.
|
||||
|
||||
@@ -1,129 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui —
|
||||
compresi i comandi (`npm run dev/lint/test`, supabase) e la checklist «Fine lavoro» da eseguire
|
||||
prima di dire che hai finito. La documentazione tecnica è indicizzata in
|
||||
[docs/README.md](docs/README.md); l'architettura in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## What this is
|
||||
|
||||
CrAPP — a mobile-first web app for managing an amateur volleyball team (CRAP Volley): attendance,
|
||||
matches/trainings/events, live match scouting, player stats/badges, CSI league standings, push
|
||||
notifications. Italian-language codebase (routes, variables, comments). Built with Lovable
|
||||
(lovable.dev); pushes to `main` sync back into the Lovable editor — avoid rewriting published git
|
||||
history (force-push, rebase/amend/squash of pushed commits).
|
||||
|
||||
## Commands
|
||||
|
||||
Package manager is **bun** (`bun.lock` is the lockfile; `package-lock.json` also exists but bun is
|
||||
primary — see `bunfig.toml`).
|
||||
|
||||
```sh
|
||||
bun run dev # vite dev server
|
||||
bun run build # production build (nitro/vite)
|
||||
bun run build:dev # development-mode build
|
||||
bun run preview # preview a build
|
||||
bun run lint # eslint .
|
||||
bun run format # prettier --write .
|
||||
```
|
||||
|
||||
There is no test runner configured in this repo.
|
||||
|
||||
Adding a dependency: `bunfig.toml` enforces a 24h supply-chain guard (`minimumReleaseAge`) on new
|
||||
packages; only pre-listed `@lovable.dev/*` packages bypass it. Confirm with the user before adding
|
||||
new bypass entries.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Stack**: TanStack Start (React 19, file-based router) + Vite, styled with Tailwind v4 +
|
||||
shadcn/radix components, data via `@supabase/supabase-js`, TanStack Query for client cache.
|
||||
|
||||
### Routing
|
||||
|
||||
File-based routing under `src/routes/` (see `src/routes/README.md`). One root layout,
|
||||
`src/routes/__root.tsx`, wraps every page — preserve its `<Outlet />`. `$id.tsx` = dynamic segment,
|
||||
`{-$category}.tsx` = optional segment, `$.tsx` = splat (`_splat` param). Do not hand-create
|
||||
`src/pages/` or Next/Remix-style layout files. `src/routeTree.gen.ts` is auto-generated — never
|
||||
edit it directly.
|
||||
|
||||
Server-only HTTP endpoints live under `src/routes/api/public/*.ts` (e.g. push subscription,
|
||||
palloni/presenze reminders) — plain HTTP handlers, not proprietary edge functions, so they can run
|
||||
under any Node host or scheduler (system cron, pg_cron, etc).
|
||||
|
||||
### Server entry / SSR error handling
|
||||
|
||||
`src/start.ts` registers global middleware: `attachSupabaseAuth` (client-side function middleware
|
||||
that attaches the Supabase bearer token to every server-fn RPC — see
|
||||
`src/integrations/supabase/auth-attacher.ts`, which is **auto-generated**, do not hand-edit) and an
|
||||
error middleware + explicit CSRF middleware (`createCsrfMiddleware`) for server functions. Defining
|
||||
`src/start.ts` opts out of Start's automatic CSRF middleware, so this file must keep re-adding it.
|
||||
|
||||
`src/server.ts` wraps the generated TanStack Start server entry and normalizes a specific h3
|
||||
failure mode: h3 swallows in-handler throws into a 500 JSON body
|
||||
(`{"unhandled":true,"message":"HTTPError"}`) that a plain try/catch never sees, so it's detected by
|
||||
inspecting the response body and converted into `renderErrorPage()`'s HTML error page.
|
||||
|
||||
### Data layer — portability rule
|
||||
|
||||
The project must remain deployable on plain Node.js + PostgreSQL, not locked into Lovable Cloud
|
||||
(see `docs/PORTABILITA.md`). Concretely:
|
||||
|
||||
- **Never query the database from components.** All data access goes through modules in
|
||||
`src/lib/*.ts` (e.g. `palloni.ts`, `mvp-voti.ts`, `eventi.ts`, `presenze.ts`, `rosa.ts`,
|
||||
`scout-*.ts`) — each exports TanStack Query hooks (`useX`) wrapping `supabase.from(...)`. This
|
||||
keeps the backend swappable in one place.
|
||||
- `src/integrations/lovable/*` (social login) is optional and unused by any screen — safe to
|
||||
remove without impact.
|
||||
- No provider-exclusive features: no edge functions, no Lovable-only auth as the sole login method,
|
||||
no proprietary storage. Config only via standard env vars (`DATABASE_URL` / `SUPABASE_URL` +
|
||||
keys, `VAPID_*`), never hardcoded.
|
||||
- SQL migrations in `supabase/migrations/` must use standard PostgreSQL, no provider-exclusive
|
||||
extensions.
|
||||
|
||||
### Cloud-efficiency rules (small paid-tier budget)
|
||||
|
||||
The Supabase/Lovable Cloud plan is metered (~20 credits/month, ~17 users), so these constraints are
|
||||
load-bearing, not style preferences:
|
||||
|
||||
- No polling (`refetchInterval`) against the database; prefer local sync via
|
||||
BroadcastChannel/storage.
|
||||
- Global QueryClient (`src/router.tsx`) is deliberately configured for infrequent refetching:
|
||||
`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` all off, `retry: 1`.
|
||||
Don't override this per-query without a reason.
|
||||
- After a mutation, update the query cache with `setQueryData` (see `useAssegnaTurno` in
|
||||
`src/lib/palloni.ts` for the pattern) rather than `invalidateQueries`, to avoid an extra read.
|
||||
- Stats/badges/standings are "write once, read many": computed and persisted once (e.g. at match
|
||||
end), never recomputed on every page open.
|
||||
- Live match scouting: only the scoring user writes; everyone else reads already-saved data.
|
||||
- CSI league data is currently a **hardcoded placeholder** array in `src/lib/crapp-data.ts`
|
||||
(`classifica`, `storicoMatch`), explicitly marked "(demo)" in the UI — no real sync exists yet,
|
||||
this is planned/open work, not an existing pattern to copy.
|
||||
- A scouted match result (`ScoutMatch`, from `scout.tsx` / `scout-store.ts`) is saved **only to
|
||||
the scoring device's `localStorage`** (key `crapp-scout-v1`), never to Supabase — only the
|
||||
in-progress resume state goes to the `scout_live` table. So today a match result is not shared
|
||||
across devices/users at all.
|
||||
- Push notifications only for high-value events (convocations, training/match reminders, ball-duty
|
||||
turn, final result) — no chat/photo/video features.
|
||||
|
||||
### Auth
|
||||
|
||||
Supabase Auth (GoTrue), attached via middleware rather than per-request boilerplate — see
|
||||
`auth-attacher.ts` / `auth-middleware.ts` above. Self-hostable; not tied to Lovable-proprietary
|
||||
auth.
|
||||
|
||||
## Project memory (`mem/`)
|
||||
|
||||
`mem/index.md` indexes feature-level design notes (`mem/features/*.md`) — currently the portability
|
||||
rule and the cloud-efficiency rules summarized above. Check there before adding a feature that
|
||||
might conflict with either constraint.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Language: Italian.** Code comments and git commit messages must be written in Italian, matching
|
||||
the rest of the codebase (routes, identifiers, existing comments/commits are already Italian).
|
||||
- Path alias `@/*` → `src/*` (see `tsconfig.json`).
|
||||
- Prettier: 100-char width, double quotes (`singleQuote: false`), trailing commas everywhere.
|
||||
- TypeScript strict mode plus extra strictness: `noUncheckedIndexedAccess`,
|
||||
`exactOptionalPropertyTypes`, `noImplicitReturns`, `noImplicitOverride`,
|
||||
`noPropertyAccessFromIndexSignature`.
|
||||
- `vite.config.ts` is intentionally minimal: `@lovable.dev/vite-tanstack-config` already bundles
|
||||
TanStack devtools, `tanstackStart`, `viteReact`, `tailwindcss`, `tsConfigPaths`, nitro, env
|
||||
injection, and the `@` alias — do not re-add any of those plugins manually or the app breaks with
|
||||
duplicate plugins.
|
||||
**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono
|
||||
anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Copyright (c) 2026 Ivan Cacciari e Davide Grilli. Tutti i diritti riservati.
|
||||
|
||||
Il presente software e la relativa documentazione (il "Software") sono di
|
||||
proprietà esclusiva di Ivan Cacciari e Davide Grilli.
|
||||
|
||||
Non è concessa alcuna licenza d'uso, salvo autorizzazione scritta esplicita
|
||||
dei titolari. È vietato copiare, modificare, distribuire, sublicenziare,
|
||||
vendere, pubblicare o comunque sfruttare in tutto o in parte il Software,
|
||||
in qualsiasi forma o con qualsiasi mezzo, senza il preventivo consenso
|
||||
scritto dei titolari.
|
||||
|
||||
IL SOFTWARE È FORNITO "COSÌ COM'È", SENZA GARANZIE DI ALCUN TIPO, ESPLICITE
|
||||
O IMPLICITE. I TITOLARI NON SONO RESPONSABILI PER QUALSIASI DANNO DERIVANTE
|
||||
DALL'USO O DALL'IMPOSSIBILITÀ DI USO DEL SOFTWARE.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 09/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
Fase corrente:
|
||||
|
||||
Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
|
||||
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
|
||||
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
|
||||
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
|
||||
reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare
|
||||
un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da
|
||||
cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||
|
||||
---
|
||||
|
||||
## Infrastruttura
|
||||
|
||||
- Si lavora direttamente su `main` (DD-019): `develop` esiste ma è fermo indietro, quindi la
|
||||
sua preview Vercel non rappresenta lo stato attuale
|
||||
- Cursor e Claude Code come ambienti di sviluppo
|
||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||
- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
|
||||
(09/09/2026)
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`)
|
||||
- Lovable Cloud: non più backend operativo di CrAPP
|
||||
- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
|
||||
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
|
||||
`npx supabase migration list`)
|
||||
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
||||
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
||||
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
||||
bisogno di una migration
|
||||
- `public.giocatori_squadra` è ora la source of truth della rosa letta dall'app (DD-015,
|
||||
03/09/2026): «Aggiungi giocatore» e «Disattiva giocatore» della dashboard admin si
|
||||
riflettono su Squadra, Presenze, Pagelle, Badge e Scout. `src/lib/crapp-data.ts` resta
|
||||
solo come seed storico, fallback offline e sorgente della data di nascita (colonna non
|
||||
ancora presente su `giocatori_squadra`)
|
||||
- Migration `m6_avatar_giocatori`: bucket pubblico `avatar-giocatori` per le foto profilo,
|
||||
al posto di `localStorage` (una per giocatore, letto da `src/lib/avatar-store.ts`)
|
||||
- Migration `m10_azzera_turni_palloni_allenamenti`: toglie i turni palloni salvati sugli
|
||||
allenamenti, che non ricevono più una proposta automatica (vedi
|
||||
[docs/modules/palloni.md](docs/modules/palloni.md))
|
||||
- Migration `m11_scritture_per_ruolo`: le policy di scrittura rispecchiano i permessi
|
||||
dell'interfaccia (DD-023). Fino a M10 un qualsiasi utente autenticato poteva svuotare il
|
||||
calendario o riscrivere il voto di un altro parlando direttamente con PostgREST; la tabella
|
||||
dei permessi sta in [docs/DATABASE.md](docs/DATABASE.md) ed è verificata da
|
||||
`test/integration/permessi.test.ts`
|
||||
- Migration `m7_scout_partite`: nuova tabella `scout_partite` per l'archivio delle partite
|
||||
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
|
||||
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
|
||||
in `localStorage`, quindi visibili a un solo dispositivo
|
||||
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
|
||||
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
|
||||
pagelle anche `pagelle_chiuse = false`)
|
||||
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
|
||||
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
|
||||
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
|
||||
`scout_sessioni`, `scout_live`, `scout_partite`)
|
||||
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
|
||||
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
|
||||
social legati a id Scout o CSI
|
||||
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
|
||||
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
|
||||
role), se mai servisse di nuovo
|
||||
|
||||
---
|
||||
|
||||
## Moduli completati
|
||||
|
||||
- Squadra
|
||||
- Presenze
|
||||
- Badge (incluso badge social)
|
||||
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
|
||||
- Pagelle
|
||||
- MVP
|
||||
- Obiettivi di squadra
|
||||
- Turno palloni (specifica in `docs/modules/palloni.md`)
|
||||
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
|
||||
- Notifiche
|
||||
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
||||
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
|
||||
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
|
||||
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
|
||||
|
||||
---
|
||||
|
||||
## Autenticazione e dashboard amministratore
|
||||
|
||||
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
|
||||
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
|
||||
permessi di amministrazione arrivano solo da `user_roles`.
|
||||
|
||||
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
|
||||
Google» risponde
|
||||
|
||||
```
|
||||
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
|
||||
```
|
||||
|
||||
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
|
||||
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
|
||||
|
||||
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
|
||||
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
|
||||
attivi hanno già fatto il primo accesso).**
|
||||
|
||||
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
|
||||
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
|
||||
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
|
||||
_Web application_ con redirect URI
|
||||
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
|
||||
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
|
||||
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
|
||||
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
|
||||
`[auth.external.google]` di `supabase/config.toml`, le due variabili
|
||||
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
|
||||
`http://127.0.0.1:54321/auth/v1/callback`.
|
||||
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
|
||||
produzione senza toccare il comportamento attuale.
|
||||
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
||||
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
||||
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
|
||||
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
|
||||
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
|
||||
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
|
||||
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
|
||||
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
|
||||
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
|
||||
slot già collegato può essere liberato solo da un admin.
|
||||
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
|
||||
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
|
||||
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
|
||||
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
|
||||
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
|
||||
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
|
||||
di accesso diretto al database bypassando l'app.
|
||||
|
||||
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
|
||||
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: tutto quello che era in lavorazione è chiuso, tesseramento CSI incluso
|
||||
(numero e data di tessera registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci
|
||||
ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
---
|
||||
|
||||
## Note
|
||||
|
||||
Il progetto segue una metodologia document-first.
|
||||
|
||||
Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata.
|
||||
@@ -1,191 +1,61 @@
|
||||
# CrAPP — CRAP Volley Hub
|
||||
# CrAPP 🏐
|
||||
|
||||
App mobile-first per la gestione della squadra amatoriale di pallavolo **CRAP Volley**:
|
||||
presenze, partite/allenamenti/eventi, scouting live, statistiche giocatori, badge,
|
||||
classifica CSI, notifiche push.
|
||||
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
|
||||
|
||||
Progetto avviato con [Lovable](https://lovable.dev): i push su `main` sincronizzano l'editor
|
||||
Lovable, quindi si evita di riscrivere la history già pubblicata (niente force-push,
|
||||
rebase/amend/squash di commit già pushati).
|
||||
## Funzionalità principali
|
||||
|
||||
**Live app**: https://volley-cronos-app.lovable.app
|
||||
- Gestione squadra
|
||||
- Gestione presenze
|
||||
- Calendario allenamenti e partite
|
||||
- Scout Live
|
||||
- Badge e gamification
|
||||
- Statistiche
|
||||
- Notifiche intelligenti
|
||||
- Gestione amministrativa
|
||||
- AI per la pianificazione degli allenamenti (in sviluppo)
|
||||
|
||||
## Stack
|
||||
## Stack tecnologico
|
||||
|
||||
- [TanStack Start](https://tanstack.com/start) (React 19, routing file-based) + Vite
|
||||
- Tailwind v4 + componenti shadcn/radix
|
||||
- [Supabase](https://supabase.com) (`@supabase/supabase-js`) per dati e auth
|
||||
- TanStack Query per la cache client
|
||||
React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, motion, vaul,
|
||||
Supabase (PostgreSQL, Auth, Storage), Vercel, GitHub. Dettagli in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Funzionalità
|
||||
## Avvio locale
|
||||
|
||||
- **Presenze**: RSVP rapido (presente / assente / forse / in ritardo / infortunato) su ogni evento
|
||||
- **Eventi**: partite, allenamenti, eventi sociali, con calendario e lista
|
||||
- **Scouting live**: registrazione azioni punto-per-punto durante la partita (riservato agli
|
||||
admin)
|
||||
- **Statistiche giocatori**: presenze totali/consecutive, badge, obiettivi squadra, medie pagelle
|
||||
- **Votazioni post-partita**: MVP, pagelle 1-10 tra compagni, voti "social"/goliardici
|
||||
- **Turno palloni**: rotazione automatica di chi porta i palloni, con promemoria push
|
||||
- **Classifica CSI**: al momento un dato demo hardcoded (vedi [CLAUDE.md](CLAUDE.md)), sync reale
|
||||
ancora da implementare
|
||||
- **Notifiche push**: convocazioni, promemoria allenamento/partita, turno palloni, esito finale
|
||||
Le dipendenze si installano con **bun** (`bun.lock`):
|
||||
|
||||
## Sviluppo (locale)
|
||||
|
||||
Package manager: **bun** ([installazione](https://bun.sh)).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ivancacciari1995-a11y/CRAPP.git
|
||||
cd CRAPP
|
||||
```bash
|
||||
bun install
|
||||
npm run dev # http://localhost:8080
|
||||
```
|
||||
|
||||
### 1. Avvia un backend Supabase
|
||||
## Comandi
|
||||
|
||||
Serve un'istanza Supabase (cloud o self-hosted) a cui puntare. Per lavorare in locale senza
|
||||
toccare dati reali, nel repo c'è uno stack self-hosted pronto in `infra/supabase/docker/`:
|
||||
|
||||
```sh
|
||||
cd infra/supabase/docker
|
||||
cp .env.example .env
|
||||
sh utils/generate-keys.sh --update-env # genera JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, ecc.
|
||||
```bash
|
||||
npm run build # build di produzione
|
||||
npm run lint # eslint (include prettier)
|
||||
npm run test # test unit; npm run test:all per la suite completa
|
||||
```
|
||||
|
||||
Poi avvia lo stack (con `docker-compose.prod.yml` si tengono su solo i servizi che CrAPP usa
|
||||
davvero — vedi la sezione Produzione più sotto):
|
||||
Chi aggiunge o modifica una funzione scrive anche il test e lo lascia verde
|
||||
([test/README.md](test/README.md)).
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
docker compose ps # verifica che tutti i container siano "healthy"
|
||||
```
|
||||
## Deploy
|
||||
|
||||
Applica le migrazioni del progetto al database appena creato:
|
||||
Deploy automatico su Vercel a ogni push su `main`, che è anche il branch di lavoro corrente.
|
||||
`develop` pubblica un Preview Deployment, ma oggi è indietro rispetto a `main`. Su quale branch
|
||||
committare lo decide chi sviluppa (DD-019).
|
||||
|
||||
```sh
|
||||
cd ../../.. # torna alla root del repo
|
||||
for f in supabase/migrations/*.sql; do
|
||||
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
|
||||
done
|
||||
```
|
||||
## Variabili d'ambiente
|
||||
|
||||
### 2. Configura `.env` di CrAPP
|
||||
Il progetto richiede le seguenti variabili:
|
||||
|
||||
Nella root del repo, crea `.env` con le chiavi lette da `infra/supabase/docker/.env`:
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_PUBLISHABLE_KEY`
|
||||
- `VITE_SUPABASE_URL`
|
||||
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
||||
|
||||
```
|
||||
VITE_SUPABASE_URL=http://localhost:8000
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY=<ANON_KEY dello stack self-hosted>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY dello stack self-hosted>
|
||||
```
|
||||
## Documentazione
|
||||
|
||||
Facoltative per testare le notifiche push (senza non si rompe nulla, semplicemente niente push):
|
||||
|
||||
```
|
||||
VAPID_PUBLIC_KEY=...
|
||||
VAPID_PRIVATE_KEY=...
|
||||
VAPID_SUBJECT=mailto:tuamail@esempio.it
|
||||
```
|
||||
|
||||
### 3. Avvia l'app
|
||||
|
||||
```sh
|
||||
bun run dev # http://localhost:8080
|
||||
```
|
||||
|
||||
Le tabelle sono vuote al primo avvio: crea un giocatore/evento dall'app stessa per avere dati di
|
||||
test. Dashboard Supabase Studio raggiungibile su `http://localhost:8000` (credenziali
|
||||
`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD` in `infra/supabase/docker/.env`).
|
||||
|
||||
Altri comandi:
|
||||
|
||||
```sh
|
||||
bun run build:dev # build in modalità development
|
||||
bun run preview # anteprima di una build
|
||||
bun run lint # eslint .
|
||||
bun run format # prettier --write .
|
||||
```
|
||||
|
||||
Non è configurato un test runner.
|
||||
|
||||
## Produzione (self-hosted, Node + Docker)
|
||||
|
||||
`bun run build` usa Nitro (tramite `@lovable.dev/vite-tanstack-config`), che di default
|
||||
compilerebbe per Cloudflare Workers — in [vite.config.ts](vite.config.ts) il preset è forzato a
|
||||
`node-server` per generare un server Node standard, deployabile su qualunque host (coerente con
|
||||
[docs/PORTABILITA.md](docs/PORTABILITA.md)).
|
||||
|
||||
Passo passo, sul server di produzione:
|
||||
|
||||
0. **Dominio/DNS**: un solo hostname pubblico basta — `Caddyfile.example` instrada `/api/*`
|
||||
verso il gateway Supabase (path stripped) e tutto il resto verso l'app, sullo stesso dominio.
|
||||
- Con un dominio vero (DNS proprio): `tuodominio.it`.
|
||||
- Con un dominio **gratuito No-IP** (`ddns.net`, ecc.): un hostname singolo basta e avanza
|
||||
(il piano free ne dà fino a 3, ma qui ne serve uno solo), es. `crapp.ddns.net`.
|
||||
- Serve un client DDNS attivo sul server (o supporto nel router) che aggiorni l'IP: gli
|
||||
hostname No-IP gratuiti scadono dopo ~30 giorni di inattività se non confermati.
|
||||
- Verifica di non essere dietro **CGNAT** (IP pubblico condiviso dall'ISP): in quel caso
|
||||
nessuna porta è raggiungibile da internet e serve un tunnel (es. Cloudflare Tunnel) al
|
||||
posto dell'esposizione diretta.
|
||||
1. **Stack Supabase self-hosted**:
|
||||
```sh
|
||||
cp infra/supabase/docker/.env.production.example infra/supabase/docker/.env
|
||||
cd infra/supabase/docker
|
||||
sh utils/generate-keys.sh --update-env # secret NUOVI, non riusare quelli di sviluppo
|
||||
```
|
||||
Modifica nel `.env` appena creato: `SUPABASE_PUBLIC_URL`/`API_EXTERNAL_URL` (dominio del
|
||||
punto 0 + `/api`), `SITE_URL` (dominio del punto 0), `DASHBOARD_PASSWORD`, e l'SMTP se
|
||||
servono email vere. Poi avvia:
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
`docker-compose.prod.yml` fa due cose, pensate anche per girare su hardware limitato (es.
|
||||
Raspberry Pi 4): non espone porte pubblicamente (solo `127.0.0.1`, dietro il reverse proxy —
|
||||
vedi `Caddyfile.example` nella stessa cartella), e disattiva i servizi Supabase che CrAPP non
|
||||
usa (Realtime, Storage, imgproxy, Edge Functions, pooler) — restano solo `db`, `auth`, `rest`,
|
||||
`api-gw`, più `studio`+`meta` per guardare/gestire il database via interfaccia grafica
|
||||
(raggiungibile su `<dominio>/api`, con le credenziali `DASHBOARD_USERNAME`/
|
||||
`DASHBOARD_PASSWORD`). Da 11 container si scende a 6.
|
||||
2. **Migrazioni**:
|
||||
```sh
|
||||
cd ../.. # root del repo
|
||||
for f in supabase/migrations/*.sql; do
|
||||
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
|
||||
done
|
||||
```
|
||||
3. **Reverse proxy TLS**: copia `infra/supabase/docker/Caddyfile.example` in `Caddyfile`,
|
||||
sostituisci il dominio del punto 0, poi `caddy run --config Caddyfile` (o come container).
|
||||
Gestisce automaticamente il certificato Let's Encrypt.
|
||||
4. **Env dell'app**:
|
||||
```sh
|
||||
cp .env.production.example .env
|
||||
```
|
||||
Compila `VITE_SUPABASE_URL` (dominio del punto 0 + `/api`),
|
||||
`VITE_SUPABASE_PUBLISHABLE_KEY` (= `ANON_KEY` dello stack), `SUPABASE_SERVICE_ROLE_KEY`
|
||||
(= `SERVICE_ROLE_KEY`), e le chiavi `VAPID_*` reali (`npx web-push generate-vapid-keys`).
|
||||
5. **Build e avvio**:
|
||||
```sh
|
||||
bun run build
|
||||
node .output/server/index.mjs # ascolta su PORT (default 3000)
|
||||
```
|
||||
Tienilo vivo con un process manager (pm2/systemd) — il Caddy del punto 3 lo espone via TLS
|
||||
sull'hostname app.
|
||||
6. **Job pianificati** — gli endpoint `src/routes/api/public/promemoria-palloni.ts` e
|
||||
`sollecita-presenze.ts` vanno richiamati periodicamente via HTTP POST da un cron di sistema o
|
||||
`pg_cron`: non partono da soli.
|
||||
|
||||
## Continuare da Lovable
|
||||
|
||||
Il progetto resta modificabile anche dall'[editor Lovable](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c):
|
||||
le modifiche fatte lì vengono committate direttamente su questo repository, e viceversa i push
|
||||
su `main` sincronizzano l'editor.
|
||||
|
||||
## Portabilità
|
||||
|
||||
Il progetto è pensato per restare deployabile su un normale server **Node.js + PostgreSQL**,
|
||||
senza dipendenze esclusive da Lovable Cloud — vedi [docs/PORTABILITA.md](docs/PORTABILITA.md)
|
||||
per lo stato attuale e le regole da rispettare.
|
||||
|
||||
## Note per chi sviluppa con Claude Code
|
||||
|
||||
Vedi [CLAUDE.md](CLAUDE.md) per architettura dettagliata, convenzioni del repo e vincoli di
|
||||
efficienza sul piano cloud a consumo.
|
||||
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
|
||||
[AGENTS.md](AGENTS.md), lo stato corrente del lavoro in [PROJECT_STATE.md](PROJECT_STATE.md).
|
||||
|
||||
@@ -3,36 +3,8 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tanstack_start_ts",
|
||||
"name": "crapp",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
@@ -40,30 +12,21 @@
|
||||
"@tanstack/react-start": "^1.168.32",
|
||||
"@tanstack/router-plugin": "^1.168.23",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^13.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"vaul": "^1.1.2",
|
||||
"vite-tsconfig-paths": "^6.0.2",
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -116,16 +79,12 @@
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
@@ -150,16 +109,6 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.5.7", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^0.7.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^3.10.3", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=3.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@sinclair/typebox", "@standard-schema/spec", "@typeschema/main", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "valibot", "vest", "yup", "zod"] }, "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
@@ -180,14 +129,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
|
||||
|
||||
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
|
||||
|
||||
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
|
||||
|
||||
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
@@ -242,112 +183,38 @@
|
||||
|
||||
"@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||
|
||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA=="],
|
||||
|
||||
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ=="],
|
||||
|
||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA=="],
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
|
||||
|
||||
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA=="],
|
||||
|
||||
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.24", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.22", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.16", "", { "dependencies": { "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.18", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw=="],
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.7", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
|
||||
|
||||
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug=="],
|
||||
|
||||
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
|
||||
|
||||
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg=="],
|
||||
@@ -380,8 +247,6 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@supabase/auth-js": ["@supabase/auth-js@2.111.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/auth-js/-/auth-js-2.111.0.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-hbRLgyQZEX0SDyF4LYXpv94qOIQyFATfpT5SIs2V0SisHnisVRkYgiPQWzv80l4S2mO3qof78rmoH9j5zoFsYQ=="],
|
||||
|
||||
"@supabase/functions-js": ["@supabase/functions-js@2.111.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/functions-js/-/functions-js-2.111.0.tgz", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw=="],
|
||||
@@ -396,8 +261,6 @@
|
||||
|
||||
"@supabase/supabase-js": ["@supabase/supabase-js@2.111.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@supabase/supabase-js/-/supabase-js-2.111.0.tgz", { "dependencies": { "@supabase/auth-js": "2.111.0", "@supabase/functions-js": "2.111.0", "@supabase/postgrest-js": "2.111.0", "@supabase/realtime-js": "2.111.0", "@supabase/storage-js": "2.111.0" } }, "sha512-9q0/AULthQnWeiDh1vGyjoJZbSY04bu6qHcWit70pqEYn5Kv/dkCPY62Ja1123jEnJbB9Vd2pjY7Kvk/lK3peA=="],
|
||||
|
||||
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
@@ -490,24 +353,6 @@
|
||||
|
||||
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -570,16 +415,12 @@
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
@@ -598,38 +439,10 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
||||
|
||||
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
|
||||
|
||||
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -638,16 +451,8 @@
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="],
|
||||
|
||||
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
|
||||
|
||||
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
|
||||
|
||||
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ=="],
|
||||
|
||||
"env-runner": ["env-runner@0.1.16", "", { "dependencies": { "crossws": "^0.4.8", "exsolve": "^1.1.0", "httpxy": "^0.5.4", "srvx": "^0.11.19" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA=="],
|
||||
@@ -680,16 +485,12 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.4.1", "", {}, "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
@@ -706,6 +507,8 @@
|
||||
|
||||
"flatted": ["flatted@3.4.3", "", {}, "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ=="],
|
||||
|
||||
"framer-motion": ["framer-motion@13.2.0", "", { "dependencies": { "motion-dom": "^13.2.0", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
@@ -738,10 +541,6 @@
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -798,12 +597,8 @@
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="],
|
||||
@@ -812,6 +607,12 @@
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"motion": ["motion@13.2.0", "", { "dependencies": { "framer-motion": "^13.2.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@13.2.0", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ=="],
|
||||
|
||||
"motion-utils": ["motion-utils@13.0.0", "", {}, "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
@@ -824,8 +625,6 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="],
|
||||
|
||||
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
|
||||
@@ -860,40 +659,22 @@
|
||||
|
||||
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-resizable-panels": ["react-resizable-panels@4.12.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q=="],
|
||||
|
||||
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||
|
||||
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.0", "", { "dependencies": { "@oxc-project/types": "=0.140.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.0", "@rolldown/binding-darwin-arm64": "1.2.0", "@rolldown/binding-darwin-x64": "1.2.0", "@rolldown/binding-freebsd-x64": "1.2.0", "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", "@rolldown/binding-linux-arm64-gnu": "1.2.0", "@rolldown/binding-linux-arm64-musl": "1.2.0", "@rolldown/binding-linux-ppc64-gnu": "1.2.0", "@rolldown/binding-linux-s390x-gnu": "1.2.0", "@rolldown/binding-linux-x64-gnu": "1.2.0", "@rolldown/binding-linux-x64-musl": "1.2.0", "@rolldown/binding-openharmony-arm64": "1.2.0", "@rolldown/binding-wasm32-wasi": "1.2.0", "@rolldown/binding-win32-arm64-msvc": "1.2.0", "@rolldown/binding-win32-x64-msvc": "1.2.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA=="],
|
||||
@@ -934,8 +715,6 @@
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
@@ -944,8 +723,6 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
@@ -974,8 +751,6 @@
|
||||
|
||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
|
||||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
@@ -1016,10 +791,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -1036,14 +807,14 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
||||
|
||||
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"vite/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@ saveTextLockfile = true
|
||||
minimumReleaseAge = 86400
|
||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||
# before adding any.
|
||||
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
|
||||
minimumReleaseAgeExcludes = []
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Architettura del progetto
|
||||
|
||||
Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di
|
||||
riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama.
|
||||
|
||||
## Stack
|
||||
|
||||
| Livello | Tecnologie |
|
||||
| ------------- | -------------------------------------------------------------------------------- |
|
||||
| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, motion, vaul |
|
||||
| Backend | Supabase (PostgreSQL, Auth, Storage) |
|
||||
| Hosting | Vercel |
|
||||
| Versionamento | Git, GitHub |
|
||||
|
||||
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone
|
||||
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
|
||||
`minimumReleaseAgeExcludes` richiede conferma esplicita.
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
src/
|
||||
components/ componenti condivisi (crapp/, ui/, motion/)
|
||||
routes/ routing file-based
|
||||
lib/ logica di dominio, un file per modulo
|
||||
integrations/ client Supabase e integrazioni esterne
|
||||
assets/
|
||||
supabase/ migration SQL
|
||||
test/ suite di test (unit, integration, end-to-end)
|
||||
docs/ documentazione ufficiale
|
||||
```
|
||||
|
||||
## Punti fermi
|
||||
|
||||
- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si
|
||||
modifica a mano.
|
||||
- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che
|
||||
include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias
|
||||
`@` → `src/`. **Non ri-aggiungere questi plugin**: l'app si rompe.
|
||||
- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare
|
||||
gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza
|
||||
`renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui
|
||||
server functions, `attachSupabaseAuth`).
|
||||
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file
|
||||
generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo
|
||||
schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite
|
||||
`client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`.
|
||||
- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica
|
||||
strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità
|
||||
del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di
|
||||
amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`).
|
||||
|
||||
## Livello dati
|
||||
|
||||
Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`,
|
||||
`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern
|
||||
ricorrente:
|
||||
|
||||
- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in
|
||||
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
|
||||
disattivati, `retry: 1`);
|
||||
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
|
||||
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più. Unica
|
||||
eccezione: `scout-live.ts`, dove il lock può essere stato preso da un altro dispositivo,
|
||||
quindi quello che abbiamo scritto non è detto sia quello che vale;
|
||||
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
|
||||
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
|
||||
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
|
||||
con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le
|
||||
route consumano `useRosa()`, non i singoli moduli.
|
||||
|
||||
Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il
|
||||
backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)).
|
||||
|
||||
Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di
|
||||
`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md).
|
||||
|
||||
Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La
|
||||
gamification deve restare equa tra ruoli (DD-008).
|
||||
|
||||
La rosa vive nella tabella `giocatori_squadra`, letta tramite `useRosa()`/`useGiocatoriSquadra()`
|
||||
(DD-015, DD-016). `src/lib/crapp-data.ts` (`rosaCSI`) resta solo come seed storico e fallback
|
||||
quando il database non risponde.
|
||||
|
||||
## UI
|
||||
|
||||
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
|
||||
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||
schermate, pochi click.
|
||||
|
||||
In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e
|
||||
`sonner`: le altre 43 non erano importate da nessuna parte (DD-021). Il resto dell'interfaccia
|
||||
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
|
||||
ombra delle superfici.
|
||||
|
||||
L'app è **solo chiara** (DD-022): non esiste un tema scuro e `:root` dichiara
|
||||
`color-scheme: light`.
|
||||
|
||||
Il movimento usa molle interrompibili di `motion` con i preset in `src/lib/molla.ts`
|
||||
(DD-021): `molla.ui` di default, `molla.slancio` solo dopo un gesto con inerzia,
|
||||
`molla.foglio` per drawer e cambi di vista. `proietta()` calcola dove finirebbe un elemento
|
||||
lanciato, così swipe come quello del calendario atterrano dove il gesto stava andando.
|
||||
`src/lib/motion.ts` conserva solo il rilevamento del movimento ridotto e i coriandoli.
|
||||
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run test # test unit (veloci, senza rete né database)
|
||||
npm run test:integration # route server vere
|
||||
npm run test:e2e # percorsi sull'app servita
|
||||
npm run test:all # tutto
|
||||
```
|
||||
|
||||
Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud:
|
||||
|
||||
```bash
|
||||
npx supabase start # avvia lo stack locale e applica tutte le migration
|
||||
npx supabase stop # spegne i container
|
||||
npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql
|
||||
npx supabase db push # applica le migration al progetto cloud
|
||||
```
|
||||
|
||||
`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché
|
||||
il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto
|
||||
dove provare le migration distruttive senza toccare i dati veri.
|
||||
|
||||
## Branch e flusso di sviluppo
|
||||
|
||||
- `main` → produzione, deploy automatico su Vercel. È anche il branch di lavoro corrente.
|
||||
- `develop` → preview Vercel; oggi indietro rispetto a `main`, non rappresenta lo stato attuale.
|
||||
- `feature/…`, `fix/…`, `refactor/…` → lavori rischiosi o paralleli.
|
||||
|
||||
Su quale branch va un commit lo decide l'utente (DD-019): un assistente AI può consigliare un
|
||||
branch dedicato, non sceglierlo. Poiché si lavora su `main`, la rete di sicurezza sono i test,
|
||||
che vanno scritti insieme al codice e devono essere verdi (DD-020, [test/README.md](../test/README.md)).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Changelog
|
||||
|
||||
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
|
||||
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
|
||||
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
|
||||
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
|
||||
registra solo _quando_ una voce è stata rilasciata e con quale versione.
|
||||
|
||||
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`):
|
||||
finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili
|
||||
all'indietro.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [0.9.2] - 2026-09-10
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
|
||||
le notifiche push attive e chi sono.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
|
||||
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
|
||||
alla volta.
|
||||
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
|
||||
su chi vede quei dati).
|
||||
|
||||
### Rimosso
|
||||
|
||||
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
|
||||
verso l'editor): l'app non ci gira più.
|
||||
|
||||
## [0.9.1] - 2026-09-10
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
|
||||
(CRAP e avversario), risultato e parziali in ordine casa–ospite (verde/rosso restano
|
||||
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
|
||||
|
||||
## [0.9.0] - 2026-09-09
|
||||
|
||||
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
|
||||
questa release riunisce tutto ciò che l'app fa oggi in produzione.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
|
||||
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
|
||||
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
|
||||
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
|
||||
certificati resta un'estensione futura.
|
||||
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
|
||||
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
|
||||
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
|
||||
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
|
||||
squadra al posto di chat e fogli condivisi.
|
||||
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
|
||||
reali della rosa.
|
||||
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
|
||||
durante la partita.
|
||||
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
|
||||
votati tra compagni.
|
||||
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
|
||||
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
|
||||
compagni, un voto a testa.
|
||||
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
|
||||
convocazioni, pagelle e risultati di campionato.
|
||||
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
|
||||
allenamenti e partite.
|
||||
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
|
||||
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
|
||||
dati della rosa, con download CSV.
|
||||
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
|
||||
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
|
||||
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
|
||||
manuale da parte degli amministratori.
|
||||
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
|
||||
presenza già registrato per le convocazioni.
|
||||
|
||||
### Sicurezza
|
||||
|
||||
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
|
||||
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Database CrAPP
|
||||
|
||||
Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo
|
||||
sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella
|
||||
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
|
||||
non in questo file.
|
||||
|
||||
## Permessi di scrittura
|
||||
|
||||
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura**
|
||||
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
|
||||
nessuna di esse da M4 (DD-011).
|
||||
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
|
||||
| `user_roles` | solo admin |
|
||||
|
||||
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
|
||||
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
|
||||
|
||||
## Anagrafica e utenti
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
|
||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
||||
|
||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
|
||||
## Storage
|
||||
|
||||
| Bucket | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m2_profili_giocatore`. |
|
||||
| `avatar-giocatori` | Foto profilo mostrate nel cerchio avatar (Squadra, Profilo), un file per giocatore (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali, non documenti sensibili. Qualsiasi autenticato può caricare/sostituire/eliminare un file (nessun controllo per-proprietario, la maggior parte dei giocatori non ha ancora `auth_user_id` collegato, DD-018). Letto da `src/lib/avatar-store.ts`. Creato dalla migration `m6_avatar_giocatori`. |
|
||||
|
||||
## Eventi e presenze
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
## Scout
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. Prima viveva solo in `localStorage`: "Scout occupato da X" non funzionava mai tra dispositivi diversi (fix M7). |
|
||||
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione di Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). Letta/scritta da `src/lib/scout-stato.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. Prima il risultato finale finiva solo in `localStorage`: invisibile a chiunque non fosse il dispositivo di chi aveva chiuso la partita (fix M7). |
|
||||
|
||||
## Votazioni
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
||||
|
||||
## Funzioni speciali
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------- | -------------------------------------- |
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
|
||||
## Badge
|
||||
|
||||
Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a
|
||||
partire dai dati esistenti (DD-007).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
# Efficienza cloud
|
||||
|
||||
Regola di progetto: CrAPP gira su un piano cloud minimo (Supabase + Vercel) per ~17 utenti.
|
||||
Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ottimizzati dopo.
|
||||
|
||||
## Regole da rispettare
|
||||
|
||||
1. **Niente polling**: mai `refetchInterval` verso il database. Per sincronizzare più schede
|
||||
aperte si usano `BroadcastChannel` o gli eventi di `storage`.
|
||||
2. **Cache lunga e passiva**: i default del `QueryClient` stanno in `src/router.tsx`
|
||||
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
|
||||
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
|
||||
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
|
||||
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione:
|
||||
`src/lib/scout-live.ts`, dove il lock di sessione può essere stato preso da un altro
|
||||
dispositivo e la riga scritta non basta a sapere chi ha vinto.
|
||||
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
|
||||
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
|
||||
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
|
||||
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
|
||||
letto.
|
||||
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno
|
||||
palloni, esito finale.
|
||||
7. **Niente funzionalità pesanti**: foto, video, chat.
|
||||
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
|
||||
|
||||
## Obiettivi non ancora attuati
|
||||
|
||||
Questi punti sono stati definiti come direzione, ma **non sono implementati**: non descrivono
|
||||
il comportamento attuale.
|
||||
|
||||
- **Dati CSI**: sincronizzazione periodica server-side salvata su una tabella locale, con
|
||||
l'app che legge solo dal database interno. Oggi la lettura è live dal portale a ogni
|
||||
richiesta, tramite `/api/public/csi` (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
|
||||
- **Aggregati persistiti**: uno schema con `statistiche_aggregate` e `classifica_csi` è stato
|
||||
ipotizzato ma non esiste; nessuna di quelle tabelle è in `supabase/migrations/`. Va valutato
|
||||
con una decisione dedicata, perché tocca DD-007 (badge e statistiche calcolati a runtime).
|
||||
+9
-10
@@ -5,16 +5,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
||||
|
||||
## Stato attuale
|
||||
|
||||
| Componente | Portabile? | Note |
|
||||
|---|---|---|
|
||||
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
|
||||
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
|
||||
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
|
||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
| Componente | Portabile? | Note |
|
||||
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
|
||||
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
|
||||
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
|
||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
|
||||
## Regole da rispettare nelle prossime modifiche
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Documentazione CrAPP
|
||||
|
||||
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
|
||||
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
|
||||
ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)).
|
||||
|
||||
## Dove sta cosa
|
||||
|
||||
| Documento | Risponde a |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
|
||||
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
|
||||
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
|
||||
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
|
||||
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
|
||||
|
||||
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
|
||||
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
## Ordine di lettura
|
||||
|
||||
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
|
||||
→ `DATABASE.md` → `DESIGN_DECISIONS.md` → il documento del modulo interessato in `modules/`.
|
||||
|
||||
## Regole di manutenzione
|
||||
|
||||
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
|
||||
|
||||
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
|
||||
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco: segue
|
||||
il formato Keep a Changelog, con il lavoro non ancora rilasciato sotto `[Non rilasciato]`
|
||||
e le voci divise per categoria (Aggiunto, Modificato, Sicurezza…);
|
||||
- il lavoro in corso sta solo in `PROJECT_STATE.md`, che rimanda alla roadmap per il resto;
|
||||
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
|
||||
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
|
||||
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
|
||||
una si copia [\_template-dd.md](_template-dd.md).
|
||||
|
||||
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
|
||||
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Roadmap
|
||||
|
||||
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
|
||||
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con quale versione,
|
||||
[PROJECT_STATE.md](../PROJECT_STATE.md) cosa si sta facendo adesso.
|
||||
|
||||
## Fatto
|
||||
|
||||
Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`).
|
||||
|
||||
- [x] Gestione squadra
|
||||
- [x] Calendario
|
||||
- [x] Presenze
|
||||
- [x] Serie di presenze
|
||||
- [x] Scout Live
|
||||
- [x] Badge
|
||||
- [x] Badge social
|
||||
- [x] Pagelle
|
||||
- [x] Votazione MVP
|
||||
- [x] Obiettivi di squadra
|
||||
- [x] Turno palloni
|
||||
- [x] Infortuni — conteggio eventi saltati, in forma minima
|
||||
- [x] Notifiche Push (promemoria intelligenti)
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
|
||||
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
|
||||
resta un'estensione futura
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica (campionato e Coppa)
|
||||
- [x] Risultati campionato
|
||||
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
|
||||
calcolata dal CSI
|
||||
|
||||
## Prossimo
|
||||
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
- [ ] Database esercizi
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
## Idee future
|
||||
|
||||
- [ ] Gestione quote
|
||||
- [ ] Calendario Google
|
||||
- [ ] Backup automatici
|
||||
- [ ] Analisi statistiche avanzate
|
||||
- [ ] Widget meteo
|
||||
- [ ] Analisi Scout con AI
|
||||
@@ -0,0 +1,21 @@
|
||||
### DD-XXX — [Titolo breve della decisione]
|
||||
|
||||
**Data:**
|
||||
**Stato:** Accettata · In valutazione · Sostituita · Obsoleta
|
||||
|
||||
**Contesto**
|
||||
[Quale problema stavamo risolvendo?]
|
||||
|
||||
**Decisione**
|
||||
[Cosa abbiamo scelto?]
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- [Alternativa 1] → [perché no]
|
||||
- [Alternativa 2] → [perché no]
|
||||
|
||||
**Conseguenze**
|
||||
[Cosa cambia per utenti, admin e team di sviluppo]
|
||||
|
||||
**Riesame**
|
||||
[Quando o in quali condizioni rivedere la decisione]
|
||||
@@ -0,0 +1,89 @@
|
||||
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
|
||||
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
|
||||
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
|
||||
-- `profili-giocatore` creato dalla migration M3.
|
||||
|
||||
CREATE TABLE public.profili_giocatore (
|
||||
giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
||||
|
||||
-- Dati personali richiesti dal tesseramento CSI
|
||||
data_nascita date,
|
||||
luogo_nascita text,
|
||||
indirizzo text,
|
||||
telefono text,
|
||||
email text,
|
||||
|
||||
-- Documento di identità
|
||||
documento_tipo text,
|
||||
documento_numero text,
|
||||
documento_rilasciato_da text,
|
||||
documento_emissione date,
|
||||
documento_scadenza date,
|
||||
-- Il documento si carica fronte e retro: il CSI li vuole entrambi.
|
||||
documento_fronte_path text,
|
||||
documento_retro_path text,
|
||||
|
||||
-- Certificato medico (storico non conservato in v1: DD-010)
|
||||
certificato_scadenza date,
|
||||
certificato_path text,
|
||||
|
||||
-- Foto tessera
|
||||
foto_path text,
|
||||
|
||||
creato_il timestamptz NOT NULL DEFAULT now(),
|
||||
aggiornato_il timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.profili_giocatore IS
|
||||
'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.';
|
||||
|
||||
CREATE TRIGGER update_profili_giocatore_aggiornato_il
|
||||
BEFORE UPDATE ON public.profili_giocatore
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_aggiornato_il();
|
||||
|
||||
ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa
|
||||
-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1).
|
||||
CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento.
|
||||
CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore
|
||||
FOR ALL TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
|
||||
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated;
|
||||
GRANT ALL ON public.profili_giocatore TO service_role;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4)
|
||||
-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e
|
||||
-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene
|
||||
-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin.
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('profili-giocatore', 'profili-giocatore', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Convenzione dei path: `<giocatore_id>/<sezione>.<estensione>` (es. `g4/certificato.pdf`).
|
||||
-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file.
|
||||
CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano
|
||||
-- in mano al giocatore che li ha caricati.
|
||||
CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||
);
|
||||
@@ -0,0 +1,471 @@
|
||||
# Modulo — Badge
|
||||
|
||||
**Stato:** implementato, coerente con DD-007 e DD-008
|
||||
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
|
||||
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
|
||||
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Gamification: sbloccare badge (gradi bronzo/argento/oro, più badge "segreti") in base a
|
||||
statistiche personali reali del giocatore, per motivare la partecipazione senza penalizzare i
|
||||
ruoli con meno statistiche "spettacolari" (DD-008).
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata ai badge sbloccati: **calcolati interamente a runtime**
|
||||
dall'oggetto `Giocatore` (DD-007). L'unica tabella coinvolta è `badge_social_voti`, per i
|
||||
badge assegnati per voto dai compagni.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
|
||||
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
|
||||
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
|
||||
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
|
||||
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
|
||||
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
|
||||
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
|
||||
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
|
||||
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
|
||||
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
|
||||
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
|
||||
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
|
||||
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
|
||||
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
|
||||
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
|
||||
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
|
||||
criterio delle presenze).
|
||||
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
|
||||
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
|
||||
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
|
||||
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
|
||||
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
|
||||
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
|
||||
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
|
||||
stessa `mediePagelle()`.
|
||||
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
|
||||
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
|
||||
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
|
||||
(modificabile), con **auto-voto escluso in interfaccia** (`VotoSocial.tsx`) e rifiutato dal
|
||||
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
|
||||
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
|
||||
`votante_id`/`votato_id` sono entrambi visibili.
|
||||
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
|
||||
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
|
||||
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
|
||||
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
|
||||
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
|
||||
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
|
||||
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
|
||||
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
|
||||
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
|
||||
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
|
||||
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
|
||||
- Il rilevamento "nuovo" (`notifiche-smart.ts`) confronta id deterministici con quelli già
|
||||
visti, salvati in `localStorage` — quindi **locale al dispositivo**, non sincronizzato tra
|
||||
dispositivi dello stesso giocatore.
|
||||
|
||||
---
|
||||
|
||||
## Elenco badge
|
||||
|
||||
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
|
||||
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
|
||||
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
||||
informazioni verso l'interfaccia.
|
||||
|
||||
### Badge normali (gradi bronzo/argento/oro)
|
||||
|
||||
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
|
||||
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
||||
|
||||
| id | nome | come si guadagna | soglie B/A/O |
|
||||
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
|
||||
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
|
||||
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
|
||||
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
||||
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### Badge segreti (booleani, nascosti finché non sbloccati)
|
||||
|
||||
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
|
||||
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
|
||||
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
|
||||
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
|
||||
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
||||
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||
|
||||
| id | nome | condizione esatta |
|
||||
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||
|
||||
### Badge social (votati dai compagni, 5 categorie per partita)
|
||||
|
||||
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
|
||||
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
|
||||
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
|
||||
voti ricevuti).
|
||||
|
||||
| id | nome | cosa premia |
|
||||
| ------------ | -------------------------- | ------------------------------------------------ |
|
||||
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
|
||||
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
|
||||
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
|
||||
| `meme` | Meme della partita | la scena più memorabile della partita |
|
||||
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
|
||||
esistenti.
|
||||
- **DD-008**: nessun `BadgeDef` usa dati di reparto (punti/ace/muri); solo statistiche
|
||||
raggiungibili da qualunque ruolo.
|
||||
|
||||
---
|
||||
|
||||
## Copertura test
|
||||
|
||||
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
|
||||
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
|
||||
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
|
||||
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
|
||||
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
|
||||
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
|
||||
end-to-end completa.
|
||||
|
||||
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
|
||||
`badges.test.ts`) sull'output di altri moduli:
|
||||
|
||||
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
|
||||
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
|
||||
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
|
||||
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
|
||||
bronzo". Più la soglia minima di voti (vedi sotto).
|
||||
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
|
||||
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
|
||||
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
|
||||
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
|
||||
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
|
||||
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
|
||||
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
|
||||
tabella.
|
||||
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
|
||||
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
|
||||
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
|
||||
gli altri badge da tabella.
|
||||
|
||||
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
|
||||
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
|
||||
voto/turni/presenze
|
||||
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
|
||||
|
||||
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
|
||||
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
|
||||
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
|
||||
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
|
||||
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
|
||||
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
|
||||
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
|
||||
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
|
||||
|
||||
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
|
||||
copertura di questo badge):
|
||||
|
||||
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
|
||||
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
|
||||
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
|
||||
(`mvp_no_autovoto`).
|
||||
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
|
||||
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
|
||||
votato non convocati vengono rifiutati.
|
||||
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
|
||||
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
|
||||
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
|
||||
deve contare come vittoria.
|
||||
|
||||
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
|
||||
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
|
||||
|
||||
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
|
||||
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
|
||||
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
|
||||
(`pagelle_no_autovoto`), già presente prima di questa sessione.
|
||||
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
|
||||
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
|
||||
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
|
||||
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
|
||||
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
|
||||
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
|
||||
applichi le soglie normali sopra soglia.
|
||||
|
||||
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
|
||||
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
|
||||
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
|
||||
|
||||
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
|
||||
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
|
||||
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
|
||||
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
|
||||
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
|
||||
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
|
||||
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
|
||||
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
|
||||
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
|
||||
pur avendo già una conferma.
|
||||
|
||||
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
|
||||
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
|
||||
M13 — vedi sotto):
|
||||
|
||||
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
|
||||
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
|
||||
filtro sui convocati, solo partite/allenamenti).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
|
||||
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
|
||||
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
|
||||
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
|
||||
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
|
||||
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
|
||||
scritta per un evento senza convocazione **non conta comunque**, perché
|
||||
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
|
||||
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
|
||||
funzione pura che il badge consuma, non solo in UI.
|
||||
|
||||
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
|
||||
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
|
||||
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
|
||||
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
|
||||
pura.
|
||||
|
||||
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
|
||||
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
|
||||
convocati che non spezzano la serie di chi non era coinvolto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
|
||||
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
|
||||
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
|
||||
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
|
||||
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
|
||||
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
|
||||
non riparte da 1).
|
||||
|
||||
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
|
||||
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
|
||||
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
|
||||
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
|
||||
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
|
||||
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
|
||||
database, non possono garantire.
|
||||
|
||||
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
|
||||
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
|
||||
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
|
||||
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
|
||||
azzera), e un evento futuro che non entra ancora nel calcolo.
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
|
||||
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
|
||||
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
|
||||
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
|
||||
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
|
||||
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
|
||||
|
||||
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
|
||||
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
|
||||
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
|
||||
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
|
||||
|
||||
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
|
||||
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
|
||||
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
|
||||
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
|
||||
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
|
||||
esattamente al confine (3 infortuni, 5 ritardi).
|
||||
|
||||
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
|
||||
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
|
||||
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
|
||||
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
|
||||
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
|
||||
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
|
||||
|
||||
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
|
||||
`cacche.test.ts` (già completo su `giornateTop`).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
|
||||
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
|
||||
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
|
||||
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
|
||||
|
||||
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
|
||||
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
|
||||
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
|
||||
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
|
||||
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
|
||||
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
|
||||
|
||||
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
|
||||
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
|
||||
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
|
||||
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
|
||||
`mvpVintiPerGiocatore()`/`mediePagelle()` → `statoBadge()` che userebbe l'app.
|
||||
|
||||
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
|
||||
segreto a combinare due statistiche indipendenti (`serieConferme()` e
|
||||
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
|
||||
|
||||
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
|
||||
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
|
||||
bastano".
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
|
||||
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
|
||||
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
|
||||
già accumulate (le due statistiche restano indipendenti anche a database).
|
||||
|
||||
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
|
||||
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
|
||||
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
|
||||
meccanismo:
|
||||
|
||||
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
|
||||
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
|
||||
vince in `m1` e `m2` → `{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
|
||||
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
|
||||
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
|
||||
mischiano in `badgeSocialVinti()`.
|
||||
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
|
||||
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
|
||||
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
|
||||
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
|
||||
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
|
||||
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
|
||||
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
|
||||
tocca il conteggio delle altre 4 nella stessa partita.
|
||||
|
||||
### Riepilogo per badge
|
||||
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
|
||||
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
|
||||
---
|
||||
|
||||
## Problemi noti da sistemare
|
||||
|
||||
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
|
||||
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
|
||||
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
|
||||
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
|
||||
CHECK o FK verso i 5 id di `categorieSocial`
|
||||
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
|
||||
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
|
||||
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
|
||||
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
|
||||
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
|
||||
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
|
||||
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
|
||||
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
|
||||
test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
|
||||
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
|
||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
||||
"ottenuto" può sparire o apparire retroattivamente.
|
||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||
browser o dispositivo.
|
||||
|
||||
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
|
||||
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
|
||||
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
|
||||
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
|
||||
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
|
||||
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
|
||||
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
|
||||
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
|
||||
conseguenza.
|
||||
|
||||
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
|
||||
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
|
||||
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
||||
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
|
||||
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
|
||||
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
|
||||
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
|
||||
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
|
||||
|
||||
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
|
||||
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
|
||||
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
|
||||
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
|
||||
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
|
||||
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
|
||||
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
|
||||
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
|
||||
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
||||
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
|
||||
ora che le serie sono calcolate.
|
||||
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
||||
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
|
||||
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Modulo — Calendario ed Eventi
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx`
|
||||
(card condivisa)
|
||||
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
|
||||
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
|
||||
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
|
||||
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
|
||||
|
||||
## Due schermate, due pubblici
|
||||
|
||||
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
|
||||
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
|
||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
|
||||
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
||||
di evento, i giorni con più eventi si dividono lo spazio.
|
||||
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
|
||||
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). Sopra alla lista
|
||||
cronologica c'è una griglia mensile (stessa logica di `/calendario`, tramite le funzioni
|
||||
condivise di `src/lib/calendario.ts`): ogni giorno è cliccabile, anche senza eventi, e apre
|
||||
un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento in
|
||||
questo giorno" che apre il form con la data già precompilata. Creare, modificare ed
|
||||
eliminare passano solo da lì: la lista cronologica sotto il calendario è un elenco senza
|
||||
azioni dirette, cliccare una riga apre lo stesso drawer del giorno corrispondente (anche se
|
||||
è in un mese diverso da quello mostrato sulla griglia) invece di duplicare matita/cestino.
|
||||
|
||||
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
|
||||
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
|
||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
|
||||
eventi virtuali (compleanni).
|
||||
|
||||
## Lettura lato server
|
||||
|
||||
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
|
||||
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
|
||||
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
|
||||
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
|
||||
(`m14_pulizia_dati_evento_cancellato`,
|
||||
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
|
||||
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
|
||||
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
|
||||
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
|
||||
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
|
||||
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
|
||||
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
|
||||
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
|
||||
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
|
||||
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
|
||||
@@ -0,0 +1,336 @@
|
||||
# Modulo — Collegamento CSI
|
||||
|
||||
**Stato:** implementato (stagione 2025/26)
|
||||
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
|
||||
(dettaglio di una gara: formazioni e scontri diretti)
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Mostrare nell'app la classifica e i risultati **ufficiali** del campionato CSI, al posto
|
||||
dei dati dimostrativi hardcoded in `crapp-data.ts`. Nessun inserimento manuale da parte
|
||||
degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere.
|
||||
|
||||
---
|
||||
|
||||
## Sorgente dati
|
||||
|
||||
Portale **Livescore CSI Bologna** (`https://livescore.csibologna.it`).
|
||||
|
||||
Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi endpoint
|
||||
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
|
||||
API key, ma **non offrono alcuna garanzia di stabilità**.
|
||||
|
||||
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
|
||||
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php` —
|
||||
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
|
||||
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
|
||||
un `project_id`), nessun codice le chiama direttamente.
|
||||
|
||||
### Identificativi (stagione 2025/26)
|
||||
|
||||
| Cosa | Valore |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` (girone) | `767` |
|
||||
| Coppa | PVM Coppa CSI Misto Silver |
|
||||
| `project_id` (coppa) | `848` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
|
||||
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
|
||||
`src/lib/csi-core.ts`.
|
||||
|
||||
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
|
||||
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
|
||||
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
|
||||
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
|
||||
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
|
||||
`team_details.php` nel browser, questo componente basta.
|
||||
|
||||
### Endpoint usati dall'app
|
||||
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
|
||||
|
||||
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
|
||||
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
|
||||
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
|
||||
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
|
||||
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
|
||||
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
|
||||
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
|
||||
condivisa, nessun parser dedicato per la Coppa.
|
||||
|
||||
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
|
||||
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
|
||||
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
|
||||
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
|
||||
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
|
||||
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
|
||||
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
|
||||
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
|
||||
|
||||
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
|
||||
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
|
||||
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
|
||||
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
|
||||
(URL del referto ufficiale, `match_details.php?id=…`).
|
||||
|
||||
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
|
||||
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
|
||||
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
|
||||
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
|
||||
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
|
||||
`team-scorers.php`.
|
||||
|
||||
### Dettaglio di una singola gara (formazioni e scontri diretti)
|
||||
|
||||
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
|
||||
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
|
||||
`assets/js/match.js`:
|
||||
|
||||
| Endpoint | Formato | Uso |
|
||||
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
|
||||
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
|
||||
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
|
||||
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
|
||||
|
||||
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
|
||||
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
|
||||
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
|
||||
riepilogo aggregato — vedi sotto).
|
||||
|
||||
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
|
||||
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
|
||||
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
|
||||
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
|
||||
interpretata come un flag booleano.
|
||||
|
||||
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
|
||||
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
|
||||
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
|
||||
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
|
||||
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
|
||||
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
|
||||
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
|
||||
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
|
||||
(referto non ancora compilato o formato cambiato).
|
||||
|
||||
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
|
||||
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
|
||||
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
|
||||
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
|
||||
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
|
||||
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
|
||||
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
|
||||
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
|
||||
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
|
||||
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
|
||||
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
|
||||
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
|
||||
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
|
||||
identificabile).
|
||||
|
||||
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
|
||||
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
|
||||
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
|
||||
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
```
|
||||
CSI (portale)
|
||||
↓ fetch server-side, cache 6 ore
|
||||
/api/public/csi → src/routes/api/public/csi.ts
|
||||
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
|
||||
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
|
||||
sotto; tab "Storico partite": ogni squadra col proprio logo,
|
||||
chevron di dettaglio sulle gare cliccabili)
|
||||
|
||||
CSI (portale, 3 endpoint)
|
||||
↓ fetch server-side on-demand, cache per-partita 6 ore
|
||||
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
|
||||
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
|
||||
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
|
||||
↓
|
||||
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
|
||||
```
|
||||
|
||||
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
|
||||
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
|
||||
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
|
||||
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
|
||||
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
|
||||
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
|
||||
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
|
||||
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
|
||||
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
|
||||
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
|
||||
del girone.
|
||||
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
|
||||
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
|
||||
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
|
||||
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
|
||||
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
|
||||
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
|
||||
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
|
||||
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
|
||||
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
|
||||
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
|
||||
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
|
||||
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
|
||||
per riga, vedi "Regole rispettate" sotto).
|
||||
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
|
||||
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
|
||||
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
|
||||
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
|
||||
(formazioni + scontri diretti, monta `useCsiPartita()`).
|
||||
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
|
||||
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
|
||||
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
|
||||
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
|
||||
(`PartitaCsi.id`), non un evento CrAPP.
|
||||
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
|
||||
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
|
||||
prima, quando `csiMatch` esiste per quella data.
|
||||
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
|
||||
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
|
||||
gara giocata.
|
||||
|
||||
### Regole rispettate
|
||||
|
||||
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
|
||||
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
|
||||
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
|
||||
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
|
||||
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
|
||||
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
|
||||
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
|
||||
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
|
||||
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
|
||||
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
|
||||
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
|
||||
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
|
||||
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
|
||||
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
|
||||
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
|
||||
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
|
||||
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
|
||||
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
|
||||
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
|
||||
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
|
||||
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
|
||||
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
||||
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
|
||||
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
|
||||
dati in una tabella Supabase riempita da un job cron (stesso pattern di
|
||||
`promemoria-palloni`).
|
||||
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
|
||||
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
|
||||
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
|
||||
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
|
||||
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
|
||||
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
|
||||
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
|
||||
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
|
||||
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
|
||||
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
|
||||
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
|
||||
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
|
||||
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
|
||||
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
|
||||
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
|
||||
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
|
||||
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
|
||||
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
|
||||
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
|
||||
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
|
||||
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
|
||||
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
|
||||
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
|
||||
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
|
||||
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
|
||||
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
|
||||
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
|
||||
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
|
||||
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
|
||||
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
|
||||
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
|
||||
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
|
||||
errore solo se *sia* la classifica *sia* le partite sono vuote insieme
|
||||
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
|
||||
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
|
||||
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
|
||||
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
|
||||
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` —
|
||||
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
|
||||
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
|
||||
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
|
||||
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
|
||||
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
|
||||
pagina campionato molto meno. Il fix, quando succede, è isolato a
|
||||
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
|
||||
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
|
||||
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
|
||||
fixture nel nuovo formato.
|
||||
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
|
||||
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
|
||||
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
|
||||
errore SQL del loro backend in chiaro al posto del JSON
|
||||
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
|
||||
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
|
||||
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
|
||||
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
|
||||
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
|
||||
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
|
||||
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
|
||||
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
|
||||
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
|
||||
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
|
||||
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
|
||||
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
|
||||
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
|
||||
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
|
||||
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
|
||||
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
|
||||
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
|
||||
`LogoSquadra` si nasconde da sola (`onError` → `null`) invece di mostrare un'icona rotta.
|
||||
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
|
||||
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
|
||||
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
|
||||
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
|
||||
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
|
||||
del CSI", non come cronaca esatta di chi ha giocato quanto.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
|
||||
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
|
||||
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
|
||||
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
|
||||
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
|
||||
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
|
||||
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
|
||||
`project-sheets.php?project_id=848` invece di essere una costante.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Modulo — Infortuni
|
||||
|
||||
**Stato:** implementato in forma minima (solo conteggio)
|
||||
**File principali:** `src/lib/infortuni.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tracciare quanti eventi (allenamenti o partite) un giocatore ha saltato per infortunio,
|
||||
riusando lo stato di presenza `infortunato` già registrato per le convocazioni — nessun
|
||||
modulo di gestione infortuni a sé stante.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata: il dato vive interamente dentro `risposte_presenze`, come uno dei
|
||||
valori possibili dell'enum `Stato` (`presente`, `assente`, `forse`, `ritardo`, `infortunato`).
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
`contaStato()`/`contaInfortuni()` (`infortuni.ts`) contano, per ciascun giocatore, quante
|
||||
volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna query
|
||||
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
|
||||
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
|
||||
|
||||
Un evento con data futura o odierna non viene contato, anche se la risposta è già registrata
|
||||
(l'UI permette di segnarsi infortunato o in ritardo su un evento non ancora passato): il
|
||||
conteggio filtra su `data < oggi`, come già fa `eventiContanoPresenze()` in
|
||||
[presenze.md](presenze.md), e cresce da solo con l'avanzare della data reale senza bisogno di
|
||||
altro codice.
|
||||
|
||||
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
|
||||
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
|
||||
profilo che mostri il numero di infortuni come statistica di superficie.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
|
||||
inizio/fine infortunio.
|
||||
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
|
||||
invece di "assente": nessuna validazione o promemoria lo garantisce. Sulle card degli
|
||||
eventi extra-campo (`tipo: "evento"`) l'opzione non è proposta in UI.
|
||||
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
|
||||
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
|
||||
nessuna parte del codice attuale — probabile residuo non collegato.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Uno StatTile dedicato nel profilo, oltre al badge segreto.
|
||||
- Se servisse un vero tracciamento (durata, tipo di infortunio), servirebbe una tabella
|
||||
dedicata: oggi il modulo copre solo il conteggio.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Modulo — Votazione MVP
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eleggere il MVP di una partita tramite voto tra compagni, un voto a testa, con vincitore
|
||||
calcolato a runtime.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
||||
partita, sovrascrivibile.
|
||||
|
||||
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
|
||||
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
|
||||
restano nel database ma non vengono più letti da nessuna schermata).
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
|
||||
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
|
||||
quando aprirà. Nessun legame con il risultato caricato.
|
||||
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
|
||||
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
|
||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
||||
limiti, senza storico.
|
||||
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
|
||||
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
|
||||
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
|
||||
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
||||
incoronare nessuno, nemmeno senza concorrenza.
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
nel profilo e in home.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
||||
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
||||
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
|
||||
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
|
||||
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
|
||||
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
||||
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
||||
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
||||
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
||||
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
||||
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
||||
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
||||
partita.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
|
||||
@@ -0,0 +1,169 @@
|
||||
# Modulo — Notifiche
|
||||
|
||||
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
|
||||
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
|
||||
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
|
||||
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
||||
indipendenti:
|
||||
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
|
||||
`auth` con cui si cifra il payload per quel dispositivo). La tabella `promemoria_push` non è
|
||||
più usata da nessuno: serviva da coda del testo quando la push partiva vuota (DD-026).
|
||||
|
||||
---
|
||||
|
||||
## Iscrizione alle notifiche push
|
||||
|
||||
In Profilo → Opzioni c’è **un solo interruttore** («Notifiche»). Non esistono preferenze
|
||||
separate per tipo di messaggio: l’iscrizione registra il dispositivo e lo rende destinatario
|
||||
di **tutte** le push (promemoria palloni, solleciti presenze) e abilita anche le notifiche
|
||||
smart in app, che usano lo stesso service worker.
|
||||
|
||||
1. Il giocatore attiva «Notifiche» in `/profilo` → richiesta permesso browser.
|
||||
2. `GET /api/public/push-config` restituisce solo la chiave pubblica VAPID.
|
||||
3. Registrazione del service worker `public/push-sw.js` e `pushManager.subscribe()`.
|
||||
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
|
||||
(upsert).
|
||||
|
||||
All'avvio e quando l'app torna visibile viene richiesto l'aggiornamento della registrazione
|
||||
push esistente con `ServiceWorkerRegistration.update()`. Non si chiede un nuovo permesso,
|
||||
non si ricrea la sottoscrizione e non si cambia l'endpoint: anche chi ha già attivato le
|
||||
notifiche deve ricevere le correzioni del worker senza spegnere e riaccendere l'interruttore.
|
||||
Gli aggiornamenti contemporanei sono accorpati; un errore di rete non blocca l'app e si
|
||||
riprova al ritorno in primo piano. Il worker attende `skipWaiting()` durante l'installazione.
|
||||
Il browser controlla anche autonomamente gli aggiornamenti: questa richiesta esplicita
|
||||
copre in particolare le sessioni lunghe della webapp (vedi il
|
||||
[ciclo di vita del service worker](https://web.dev/articles/service-worker-lifecycle)).
|
||||
|
||||
---
|
||||
|
||||
## Ruolo delle tre route pubbliche
|
||||
|
||||
- **`push-config`** — espone la sola chiave pubblica VAPID.
|
||||
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
|
||||
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i
|
||||
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi
|
||||
[Scout Live](scout-live.md)).
|
||||
|
||||
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
|
||||
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
|
||||
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
|
||||
|
||||
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
|
||||
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
|
||||
rete. È il punto decisivo per la consegna ad app chiusa — il browser sveglia il worker per
|
||||
pochi secondi, e una fetch per recuperare il testo lo faceva morire prima di
|
||||
`showNotification` (DD-026).
|
||||
|
||||
La POST porta `Urgency: high`. Con l'urgenza predefinita ("normal") un telefono in risparmio
|
||||
energetico accumula i messaggi fino al risveglio: la notifica arriva solo quando il
|
||||
dispositivo è già attivo — cioè, nella pratica, solo con l'app aperta.
|
||||
|
||||
### Chi può farle partire (DD-024, DD-025)
|
||||
|
||||
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
|
||||
route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo
|
||||
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
|
||||
variabile d'ambiente.
|
||||
|
||||
| Route | Controllo | Chi la chiama |
|
||||
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||
|
||||
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
|
||||
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
|
||||
l'ha ancora attivata.
|
||||
|
||||
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
|
||||
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
|
||||
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
|
||||
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
|
||||
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
|
||||
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
|
||||
|
||||
---
|
||||
|
||||
## Notifiche smart
|
||||
|
||||
`calcolaNotifiche()` (`notifiche-smart.ts`) genera un evento solo quando "c'è qualcosa di
|
||||
reale": badge appena sbloccato, "sei a un passo" da un traguardo, serie che raggiunge un
|
||||
traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. Ogni notifica
|
||||
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
|
||||
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
||||
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
|
||||
- La tabella `promemoria_push` è rimasta nel database ma non la usa più nessuno (DD-026): va
|
||||
eliminata con una migrazione alla prossima occasione.
|
||||
- **Un 2xx dal server push non significa consegnato.** FCM accetta con 201 anche verso
|
||||
registrazioni scadute e poi butta via il messaggio, senza il 404/410 che farebbe pulire
|
||||
`push_subscriptions`. Il conteggio "inviate a N dispositivi" va letto come "accettate da N
|
||||
server push", non come "arrivate a N telefoni".
|
||||
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
|
||||
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
|
||||
esplicitamente. È il primo sospetto quando una notifica non arriva ad app chiusa su iPhone.
|
||||
- **Su Android non riceve la webapp: riceve il browser.** Il WebAPK è solo l'identità con
|
||||
cui la notifica viene mostrata; la connessione con i server push la tiene Chrome, tramite
|
||||
Google Play Services. Se Android non può avviare Chrome, il messaggio resta in coda e
|
||||
compare tutto insieme al lancio successivo — il sintomo classico è «arriva solo quando
|
||||
riapro l'app». Un 201 dal servizio push non lo distingue in alcun modo da una consegna
|
||||
riuscita.
|
||||
|
||||
Verificato sul campo (settembre 2026, Motorola): con Chrome vivo in secondo piano la push
|
||||
arriva ad app chiusa e schermo bloccato, WebAPK compreso — quindi server, cifratura,
|
||||
service worker, permesso notifiche e canale erano già corretti. L'unica condizione che
|
||||
fallisce è **Chrome non in esecuzione**. La cura sta in Impostazioni → App → **Chrome** →
|
||||
Batteria → «Senza restrizioni», più Impostazioni → Batteria → «Batteria adattiva»
|
||||
disattivata. Mettere «Senza restrizioni» solo su CrAPP non basta e depista.
|
||||
|
||||
**Come misurarlo invece di indovinare:** `chrome://gcm-internals` sul telefono, sezione
|
||||
«Receive Message Log». Se la riga porta l'orario dell'invio, il messaggio era arrivato e
|
||||
non è stato mostrato (permesso o canale); se porta l'orario in cui si è riaperta l'app,
|
||||
non era stato consegnato (risveglio, quindi batteria). Attenzione: tenere quella scheda
|
||||
aperta **tiene Chrome vivo**, quindi falsa la prova stretta — per quella, nessuna scheda
|
||||
aperta e Chrome tolto dai recenti.
|
||||
|
||||
- Su Motorola verificare anche le restrizioni del **browser che ha installato CrAPP** e,
|
||||
dove presente, Impostazioni → Batteria → Ottimizzazione standby app. Il produttore
|
||||
documenta la limitazione dei processi in background
|
||||
([guida Motorola](https://help.motorola.com/hc/3505/14/global/en-us/CG2007980805.html)).
|
||||
È una possibile causa del sintomo, non una diagnosi verificata sul dispositivo: il
|
||||
codice web non può rimuovere questi vincoli. La verifica richiede un invio da un altro
|
||||
dispositivo mentre CrAPP è chiusa e lo schermo del Motorola è bloccato. Il pulsante di
|
||||
prova invia subito, quindi da solo non dimostra la ricezione in background.
|
||||
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
|
||||
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
|
||||
locale non viene mai mostrata, anche con permesso concesso.
|
||||
- Il payload cifrato non può superare i ~4 KB: i testi attuali stanno larghi, ma un messaggio
|
||||
molto lungo verrebbe rifiutato dal servizio push.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
|
||||
- Eliminare `promemoria_push` con una migrazione.
|
||||
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
|
||||
@@ -0,0 +1,174 @@
|
||||
# Modulo — Obiettivi di squadra
|
||||
|
||||
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
||||
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Mostrare traguardi collettivi (non individuali) che avanzano con il contributo di tutta la
|
||||
rosa — presenze, risposte alle convocazioni, pagelle, risultati di campionato — per motivare
|
||||
comportamenti di squadra oltre alla singola prestazione.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
|
||||
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
|
||||
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
|
||||
ordina mettendo i completati in coda e gli altri per progresso decrescente.
|
||||
|
||||
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
|
||||
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
|
||||
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
|
||||
esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle
|
||||
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
|
||||
cancellazione: risolto a livello database con un trigger (migration
|
||||
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
|
||||
|
||||
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
|
||||
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
|
||||
corrente dinamico (vedi sotto).
|
||||
|
||||
---
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||
|
||||
---
|
||||
|
||||
## Obiettivi mensili — mese dinamico
|
||||
|
||||
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
|
||||
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
||||
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
|
||||
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||
seguono di conseguenza.
|
||||
|
||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
|
||||
|
||||
---
|
||||
|
||||
## Continuità di squadra — il target 12 è il minimo per un 6vs6
|
||||
|
||||
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
|
||||
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
|
||||
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
|
||||
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
|
||||
|
||||
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
|
||||
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
|
||||
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
|
||||
|
||||
---
|
||||
|
||||
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
|
||||
|
||||
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
|
||||
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
|
||||
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
|
||||
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
|
||||
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
|
||||
proprio target, nemmeno con più vittorie di quante ne servano.
|
||||
|
||||
### Il limite: il parsing del JSON può rompersi in silenzio
|
||||
|
||||
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
|
||||
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
|
||||
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
|
||||
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
|
||||
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
|
||||
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
|
||||
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
|
||||
errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo
|
||||
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
|
||||
comunque `200` con `partite: []`.
|
||||
|
||||
### Come è mitigato oggi
|
||||
|
||||
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
|
||||
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
|
||||
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
|
||||
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
|
||||
anche loro).
|
||||
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
|
||||
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
|
||||
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
|
||||
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
|
||||
|
||||
### Come fixarlo, se succede
|
||||
|
||||
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
|
||||
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
|
||||
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
|
||||
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
|
||||
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
|
||||
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
|
||||
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
|
||||
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
|
||||
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
|
||||
restano verdi contro un formato che non esiste più.
|
||||
|
||||
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
|
||||
[Collegamento CSI](collegamento-csi.md).
|
||||
|
||||
---
|
||||
|
||||
## Copertura test
|
||||
|
||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ------------ | -------------------------------- | ------------------------------------------------------------ |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
|
||||
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||
(evento dentro/fuori mese), scadenza dinamica.
|
||||
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
|
||||
arrotondata e conteggio.
|
||||
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
|
||||
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
|
||||
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
|
||||
verifica cap e target su dati reali.
|
||||
|
||||
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
|
||||
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
|
||||
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Modulo — Pagelle
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Voto tra compagni (1-10) a fine partita per ciascun convocato, usato per calcolare una media
|
||||
personale mostrata nel profilo e una media di squadra.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `pagelle_voti`, con vincoli imposti a livello database: `CHECK voto BETWEEN 1 AND 10`,
|
||||
`CHECK votante_id <> votato_id` (anti auto-voto imposto anche dal database, non solo dalla
|
||||
UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- Il pannello `Pagelle` compare in `partita.$id.tsx` solo se esiste un risultato per la
|
||||
partita (scout salvato o dato CSI).
|
||||
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
|
||||
dal vincolo DB.
|
||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||
volte, l'ultimo voto sovrascrive il precedente.
|
||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
|
||||
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
|
||||
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
|
||||
`squadra.tsx`.
|
||||
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
|
||||
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
|
||||
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
|
||||
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
|
||||
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
|
||||
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
|
||||
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
|
||||
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
|
||||
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
|
||||
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
|
||||
`evento_permette_voto()`, `pagelle_voti`).
|
||||
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
|
||||
database, non solo in UI (stessa RLS di sopra).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
|
||||
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
|
||||
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
|
||||
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
|
||||
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
|
||||
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
|
||||
considerarla — profilo e squadra no.
|
||||
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
|
||||
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
|
||||
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Modulo — Palloni
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
|
||||
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
|
||||
`src/routes/api/public/promemoria-palloni.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
||||
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
|
||||
giorno stesso.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `turni_palloni` (`evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`) —
|
||||
contiene solo i turni **confermati manualmente**; le proposte automatiche non salvate non vi
|
||||
compaiono.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
|
||||
turno già salvato, il candidato con meno turni fatti, poi quello che non lo fa da più
|
||||
tempo, poi per ordine alfabetico — un algoritmo greedy, non un ordine fisso né solo per
|
||||
data. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
|
||||
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
|
||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||
upsert su `evento_id`.
|
||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
|
||||
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
|
||||
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
|
||||
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
|
||||
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
|
||||
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
|
||||
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
|
||||
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
|
||||
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
|
||||
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
|
||||
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
|
||||
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
|
||||
è il banner in Home per il giocatore di turno.
|
||||
|
||||
---
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
|
||||
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
|
||||
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
|
||||
palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
|
||||
per ciascuno.
|
||||
|
||||
Titolo e testo viaggiano cifrati dentro la push, quindi il service worker li mostra senza
|
||||
nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
[Notifiche](notifiche.md)).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
||||
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
||||
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
|
||||
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
|
||||
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
|
||||
lato dashboard.
|
||||
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Modulo — Presenze
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
|
||||
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Permettere a ogni giocatore di confermare o rifiutare la propria partecipazione a un evento
|
||||
(allenamento o partita) e mostrare a tutta la squadra chi ha risposto e come, sostituendo i
|
||||
solleciti a voce o su chat esterne.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `risposte_presenze` (PK composita `evento_id, giocatore_id`), letta e scritta da
|
||||
`src/lib/presenze.ts`. È il modello "in uso" citato in `docs/DATABASE.md`; le tabelle
|
||||
`eventi`/`presenze` previste da DD-014 non sono referenziate da nessun punto del codice
|
||||
attuale.
|
||||
|
||||
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
||||
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
|
||||
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
|
||||
risposto". Sulla card in home/calendario (`EventoCard`) lo stato `infortunato` è offerto solo
|
||||
per partite e allenamenti; sugli eventi extra-campo non è selezionabile (non pertinente).
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
```
|
||||
Giocatore tocca uno stato in RosaPresenze
|
||||
↓
|
||||
useSalvaPresenza() → src/lib/presenze.ts (upsert o delete su risposte_presenze,
|
||||
↓ onConflict evento_id+giocatore_id)
|
||||
risposte_presenze (Supabase)
|
||||
↓ letta da
|
||||
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
|
||||
↓ legge tutta la tabella)
|
||||
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
|
||||
allenamento.$id.tsx / partita.$id.tsx
|
||||
|
||||
EventoCard → src/components/crapp/EventoCard.tsx
|
||||
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
||||
|
||||
--- statistiche ---
|
||||
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
|
||||
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
|
||||
(percentuale ultimi 30gg, da cache già in memoria)
|
||||
|
||||
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
|
||||
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||
|
||||
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
|
||||
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
|
||||
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
|
||||
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||
|
||||
--- sollecito (solo admin) ---
|
||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||
↓
|
||||
src/routes/api/public/sollecita-presenze.ts
|
||||
├─ legge l'evento (eventi_app) e le risposte già date
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
|
||||
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
|
||||
(DD-024).
|
||||
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
|
||||
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
|
||||
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
|
||||
(DD-023). Verificato da `test/integration/permessi.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato.
|
||||
- La risposta di un evento passato resta modificabile: `risposte_presenze` non ha una
|
||||
finestra di chiusura, né in UI né in RLS.
|
||||
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
|
||||
una singola squadra, da rivedere se il volume cresce molto.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Modulo — Profilo Giocatore
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
|
||||
`src/routes/admin.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
|
||||
|
||||
L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel.
|
||||
|
||||
## Utenti
|
||||
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
|
||||
- visualizzare il proprio profilo
|
||||
- modificare i propri dati personali
|
||||
- aggiornare il certificato medico
|
||||
- aggiornare i documenti
|
||||
- caricare le immagini richieste
|
||||
|
||||
### Amministratore
|
||||
|
||||
Può:
|
||||
|
||||
- visualizzare il profilo di tutti i giocatori
|
||||
- scaricare documenti e certificati
|
||||
- esportare i dati necessari al tesseramento CSI
|
||||
- verificare lo stato di completamento dei profili
|
||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
|
||||
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
||||
- scollegare un account da un profilo, liberando lo slot
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
|
||||
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
|
||||
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id
|
||||
|
||||
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
|
||||
responsabilità del giocatore che li fornisce.
|
||||
|
||||
## Flusso utente
|
||||
|
||||
### Primo accesso
|
||||
|
||||
1. Login tramite Google oppure Email. _Implementato con il solo Google: la squadra ha tutti
|
||||
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
|
||||
schermata) il giorno che serve._
|
||||
2. Collegamento automatico al proprio giocatore, confrontando l'email dell'account Google
|
||||
con l'email registrata in `giocatori_squadra` (DD-018). Nessuna scelta manuale: se
|
||||
l'email non corrisponde a nessun profilo, l'accesso si ferma con un messaggio che invita
|
||||
a contattare un amministratore.
|
||||
3. Accesso alla Home.
|
||||
|
||||
Se il profilo non è completo compare automaticamente un widget di completamento.
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_), composta dalle seguenti sezioni.
|
||||
|
||||
- Dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Foto tessera
|
||||
|
||||
Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
|
||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||
sulla tab Stagione.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
|
||||
### Dati Giocatore
|
||||
|
||||
**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Numero di maglia
|
||||
- Ruolo
|
||||
|
||||
**Dati personali** — modificabili dal giocatore.
|
||||
|
||||
- Data di nascita
|
||||
- Luogo di nascita
|
||||
- Indirizzo di residenza
|
||||
- Telefono
|
||||
- Email
|
||||
|
||||
### Documento di identità
|
||||
|
||||
Campi.
|
||||
|
||||
- Tipo documento
|
||||
- Numero documento
|
||||
- Rilasciato da
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
Upload.
|
||||
|
||||
- Foto fronte
|
||||
- Foto retro
|
||||
|
||||
### Certificato medico
|
||||
|
||||
Campi.
|
||||
|
||||
- Data di scadenza
|
||||
|
||||
Upload.
|
||||
|
||||
- Certificato medico
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
|
||||
Upload di una fotografia formato tessera.
|
||||
|
||||
Utilizzata dagli amministratori per il tesseramento CSI.
|
||||
|
||||
### Statistiche
|
||||
|
||||
Sezione già presente. Contiene.
|
||||
|
||||
- Presenze
|
||||
- Voto medio
|
||||
- MVP
|
||||
- Serie
|
||||
- Altre statistiche disponibili
|
||||
|
||||
### Badge
|
||||
|
||||
Sezione già presente.
|
||||
|
||||
Contiene tutti i badge ottenuti e quelli ancora da sbloccare.
|
||||
|
||||
### Opzioni
|
||||
|
||||
Contiene.
|
||||
|
||||
- Logout
|
||||
- Preferenze notifiche: un solo interruttore che iscrive il dispositivo a **tutte** le push
|
||||
(turno palloni, solleciti presenze) e abilita le notifiche smart in app — non è limitato
|
||||
ai soli palloni (vedi [Notifiche](notifiche.md))
|
||||
- Impostazioni applicazione
|
||||
- Segnala un bug e Suggerisci una nuova funzionalità: due link che aprono una issue GitHub
|
||||
già impostata sul template giusto (`.github/ISSUE_TEMPLATE/bug_report.yml` e
|
||||
`feature_request.yml`). Nessun dato passa dall'app — la segnalazione vive interamente su
|
||||
GitHub, così non servono né una tabella né una schermata di gestione.
|
||||
|
||||
## Dashboard amministratore
|
||||
|
||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
|
||||
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
|
||||
c'è almeno un giocatore disattivato) e Notifiche.
|
||||
|
||||
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
|
||||
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
|
||||
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
|
||||
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||
di altri.
|
||||
|
||||
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
- Documento di identità
|
||||
- Foto tessera
|
||||
- Stato tesseramento CSI (tesserato / da tesserare)
|
||||
|
||||
Azioni disponibili.
|
||||
|
||||
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
|
||||
- Scarica certificato
|
||||
- Scarica documento
|
||||
- Scarica foto tessera
|
||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
|
||||
- Scollega account, per liberare uno slot assegnato per errore
|
||||
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
||||
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
||||
|
||||
## Esportazione CSI
|
||||
|
||||
Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento.
|
||||
|
||||
Campi esportati.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Data di nascita
|
||||
- Luogo di nascita
|
||||
- Indirizzo
|
||||
- Telefono
|
||||
- Email
|
||||
- Tipo documento
|
||||
- Numero documento
|
||||
- Rilasciato da
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
## Tracciamento tesseramento
|
||||
|
||||
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
||||
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
||||
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
||||
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
|
||||
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
|
||||
complessivo della squadra.
|
||||
|
||||
## Completamento profilo
|
||||
|
||||
Ogni sezione contribuisce alla percentuale di completamento.
|
||||
|
||||
| Sezione | Peso |
|
||||
| --------------------- | ---- |
|
||||
| Dati personali | 30% |
|
||||
| Documento di identità | 30% |
|
||||
| Certificato medico | 30% |
|
||||
| Foto tessera | 10% |
|
||||
|
||||
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
|
||||
## Permessi
|
||||
|
||||
**Giocatore** — può modificare esclusivamente il proprio profilo.
|
||||
|
||||
**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
- Completamento profilo
|
||||
- Gestione dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Foto tessera
|
||||
- Dashboard amministratore
|
||||
- Esportazione CSV CSI
|
||||
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
- Verifica automatica documenti
|
||||
@@ -0,0 +1,130 @@
|
||||
# Modulo — Scout Live
|
||||
|
||||
**Stato:** implementato (fix M7 per la persistenza condivisa)
|
||||
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
|
||||
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
|
||||
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
|
||||
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su
|
||||
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
|
||||
aggiornato da qualunque dispositivo.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
|
||||
`evento_id`, quindi un solo detentore).
|
||||
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione.
|
||||
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni),
|
||||
mai più modificato una volta salvato (solo eliminabile per intero).
|
||||
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
|
||||
(`UNIQUE evento_id, giocatore_id`).
|
||||
|
||||
---
|
||||
|
||||
## Chi può usarlo
|
||||
|
||||
Chiunque sia autenticato: non è più riservato agli admin. A tenere l'ordine basta il lock di
|
||||
sessione — scoutizza uno per volta, gli altri vedono "In uso da …". Questo allinea l'interfaccia
|
||||
alle policy RLS di `scout_sessioni`/`scout_live`/`scout_partite`, che sono sempre state aperte a
|
||||
qualunque utente autenticato (la migration M4 toglie l'accesso solo al ruolo `anon`).
|
||||
|
||||
---
|
||||
|
||||
## Da dove ci si arriva
|
||||
|
||||
`ScoutEntry.tsx` è l'unico accesso a `/scout`: sta nella pagina della partita
|
||||
(`partita.$id.tsx`, sezione «Scout live»), visibile a tutta la squadra. Si accende solo se
|
||||
**quella** partita è quella di oggi — la prop `eventoId` confronta l'evento aperto con
|
||||
`partitaDiOggi()` — e se nessun altro ha il lock; negli altri casi resta una card grigia non
|
||||
cliccabile («Si attiva il giorno della partita» / «In uso da …»). Dalla home è stato tolto
|
||||
perché occupava spazio 6 giorni su 7.
|
||||
|
||||
---
|
||||
|
||||
## Meccanismo di lock condiviso
|
||||
|
||||
- `useApriSessioneScout()` (`scout-live.ts`) prende il controllo con un upsert su
|
||||
`scout_sessioni` (chiave `evento_id`), rifiutando se un altro giocatore ha già una sessione
|
||||
non scaduta.
|
||||
- Una sessione scade dopo 5 minuti di inattività; `useHeartbeatScout()` la rinnova ogni 60
|
||||
secondi finché lo scout resta aperto.
|
||||
- Il rilascio (`useChiudiSessioneScout()`) avviene al bottone "Rilascia", a fine partita, e
|
||||
sull'evento `pagehide` della finestra (per liberare il lock se il browser viene chiuso senza
|
||||
uscire esplicitamente).
|
||||
- Nessun realtime: la sessione si rilegge solo all'apertura/focus pagina o al bottone
|
||||
"Aggiorna" (`staleTime` 30s).
|
||||
|
||||
---
|
||||
|
||||
## Cosa registra
|
||||
|
||||
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`,
|
||||
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro,
|
||||
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
|
||||
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati
|
||||
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha
|
||||
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento.
|
||||
|
||||
---
|
||||
|
||||
## Fine partita
|
||||
|
||||
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
|
||||
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
|
||||
e rilascia la sessione.
|
||||
|
||||
---
|
||||
|
||||
## Export CSV
|
||||
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per
|
||||
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita,
|
||||
sezione "Report tecnico".
|
||||
|
||||
---
|
||||
|
||||
## Sondaggio cacche
|
||||
|
||||
`SondaggioCacche.tsx` chiede "quante cacche hai fatto prima di questa partita" (0-5+), sempre
|
||||
modificabile. `sondaggioAperto()` (`cacche.ts`) lo apre alle **8:00 del giorno della partita**
|
||||
(ora locale del dispositivo) e da lì lo lascia aperto per sempre; prima la card mostra solo
|
||||
l'avviso di apertura. Quando è aperto, gli **amministratori** vedono nella card il pulsante
|
||||
«Avvisa tutti del sondaggio»: chiama `POST /api/public/apri-sondaggio` e manda la push a tutti
|
||||
i dispositivi iscritti, come il sollecito presenze (vedi [Notifiche](notifiche.md)). Nessun
|
||||
invio automatico: parte solo quando un admin lo preme.
|
||||
`statisticheCacche()` (`cacche.ts`) calcola media, record e `giornateTop`
|
||||
(giornate con ≥3), soglia usata per un [badge](badge.md) segreto — coerente con DD-007 (badge
|
||||
calcolati a runtime).
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri) restano confinati allo Scout
|
||||
Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o
|
||||
classifiche individuali.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
|
||||
garanzia è il lock di sessione (vedi sopra).
|
||||
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
|
||||
lettura e upsert non sono atomici.
|
||||
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
|
||||
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
|
||||
partite cadono lo stesso giorno.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Modulo — Serie di presenze
|
||||
|
||||
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||||
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
||||
`src/components/crapp/SerieCard.tsx`
|
||||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||||
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
|
||||
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Motivare la costanza dei giocatori mostrando "serie" (streak) di comportamenti positivi
|
||||
consecutivi — presenza agli allenamenti, presenza alle partite, risposta entro 24 ore alla
|
||||
convocazione — con traguardi progressivi, sullo stile delle app fitness. È anche uno dei
|
||||
requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](obiettivi-squadra.md).
|
||||
|
||||
---
|
||||
|
||||
## Le tre serie in sintesi
|
||||
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
|
||||
|
||||
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
||||
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||||
`src/routes/profilo.tsx`.
|
||||
|
||||
Le serie sono **indipendenti**: un buco agli allenamenti non tocca partite e conferme. È la
|
||||
regola scritta in `aggiornaSerie()` e va mantenuta se si aggiungono altre serie.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Non esiste una tabella delle serie e non c'è nessun contatore salvato: **le serie sono
|
||||
ricalcolate da zero a ogni render**, partendo dagli eventi e dalle risposte già in cache
|
||||
React Query. Nessuna query aggiuntiva, nessuna migration da rifare quando si cambia una
|
||||
regola, nessun rischio di contatori disallineati dalla realtà.
|
||||
|
||||
Conseguenza pratica: se domani si inseriscono le presenze di eventi passati (import,
|
||||
backfill, correzione a mano), le serie si aggiornano da sole al caricamento successivo.
|
||||
|
||||
### Tabelle lette
|
||||
|
||||
| Tabella | Colonne usate | A cosa servono |
|
||||
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `eventi_app` | `id`, `tipo`, `data`, `convocati`, `creato_il` | Quali impegni contano, in che ordine, e quando è partita la convocazione |
|
||||
| `risposte_presenze` | `evento_id`, `giocatore_id`, `stato`, `risposto_il` | Se l'impegno è stato onorato e quanto in fretta è arrivata la risposta |
|
||||
|
||||
`risposto_il` (migration `m9`) è l'istante della **prima** risposta del giocatore per quell'
|
||||
evento. Un trigger (`risposte_presenze_risposto_il_immutabile`) lo blocca su qualsiasi
|
||||
UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo risulterebbe
|
||||
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
|
||||
per le conferme.
|
||||
|
||||
Le due colonne si confondono facilmente, e sbagliarle non rompe niente di visibile: la serie
|
||||
comincia solo a raccontare il falso. Per questo il confine è verificato in
|
||||
`test/integration/scritture.test.ts` («la risposta di presenza si aggiorna senza far ripartire
|
||||
il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto_il` e controlla
|
||||
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
||||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||||
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | ----------------------- |
|
||||
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
|
||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||||
|
||||
---
|
||||
|
||||
## Flusso completo
|
||||
|
||||
```
|
||||
eventi_app ─┐
|
||||
├─► useEventi() ─┐
|
||||
risposte_ │ (src/lib/eventi.ts) │
|
||||
presenze ─┘ ├─► useRosa() ─► Giocatore.serie* ─┐
|
||||
useRispostePresenze() ─┘ (rosa.ts) │
|
||||
(presenze.ts) │
|
||||
▼
|
||||
serieGiocatore() / serieMigliore()
|
||||
(serie.ts, applica serieDefs)
|
||||
│
|
||||
┌────────────────────────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
SerieGriglia SerieHome badges.ts
|
||||
(profilo) (home) obiettivi.ts
|
||||
```
|
||||
|
||||
Chi calcola cosa:
|
||||
|
||||
- **`src/lib/presenze.ts`** — i tre numeri, dai dati grezzi.
|
||||
- **`src/lib/rosa.ts`** — li attacca a ogni `Giocatore` dentro l'unica `useMemo` di `useRosa()`.
|
||||
- **`src/lib/serie.ts`** — definizioni, traguardi, progresso e microcopy: da un numero a uno stato mostrabile.
|
||||
- **`src/components/crapp/SerieCard.tsx`** — la resa a schermo.
|
||||
|
||||
---
|
||||
|
||||
## Il calcolo (`src/lib/presenze.ts`)
|
||||
|
||||
Tutte le serie passano dalla stessa funzione privata `serieSu()`, che fa quattro cose in
|
||||
ordine:
|
||||
|
||||
1. **Filtra gli eventi rilevanti** con `eventiContanoPresenze()` — la stessa funzione che
|
||||
alimenta il conteggio presenze, così le due statistiche non possono divergere:
|
||||
- solo `tipo` `partita` o `allenamento` (mai `evento` o `compleanno`);
|
||||
- solo eventi a cui il giocatore era convocato. **`convocati` vuoto significa "tutta la
|
||||
rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
|
||||
quell'evento e la sua serie non si spezza.
|
||||
2. **Tiene solo gli eventi già passati** (`e.data < oggi`, dentro `eventiContanoPresenze()`).
|
||||
Il confronto è **stretto**: l'evento di oggi non conta ancora, perché nessuno ha potuto
|
||||
presentarsi e conterebbe come assenza, azzerando la serie di tutta la squadra la mattina
|
||||
della partita. Entra in gioco dal giorno dopo. Il parametro `oggi` è iniettabile — di
|
||||
default `dataOggi()` — e i test lo fissano a una data per non dipendere dall'orologio.
|
||||
3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
|
||||
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato,
|
||||
`0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
|
||||
|
||||
Quel che cambia fra le serie è solo il predicato `onorato`.
|
||||
|
||||
### `serieConsecutiva()` — allenamenti, partite, `streak`
|
||||
|
||||
```ts
|
||||
serieConsecutiva(giocatoreId, eventi, presenze, tipo?, oggi?)
|
||||
```
|
||||
|
||||
Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili sono
|
||||
`presente | assente | forse | ritardo | infortunato` (`src/lib/crapp-data.ts`).
|
||||
|
||||
Conseguenze da conoscere prima di cambiare qualcosa:
|
||||
|
||||
- **`infortunato` congela la serie**: l'evento è escluso a monte (filtrato prima di
|
||||
`serieSu()`), quindi non conta né come presenza né come buco — la serie resta al valore
|
||||
di prima. Diverso da `contaPresenzeGiocatore()`, che continua a non contarlo come
|
||||
presenza (stesso criterio `presente`/`ritardo` di prima, invariato).
|
||||
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
|
||||
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
|
||||
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
||||
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
||||
|
||||
### `serieConferme()` — conferme entro 24 ore
|
||||
|
||||
```ts
|
||||
serieConferme(giocatoreId, eventi, tempi, oggi?)
|
||||
```
|
||||
|
||||
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
|
||||
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
|
||||
|
||||
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
|
||||
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
|
||||
sulla reattività, non sulla presenza.
|
||||
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
|
||||
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
|
||||
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
|
||||
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
|
||||
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
|
||||
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
|
||||
l'istante giusto da confrontare.
|
||||
|
||||
### Lettura e cache
|
||||
|
||||
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
||||
|
||||
```ts
|
||||
presenze: { [eventoId]: { [giocatoreId]: Stato } }
|
||||
tempi: { [eventoId]: { [giocatoreId]: string /* ISO */ } }
|
||||
```
|
||||
|
||||
Entrambe vivono nella stessa entry di React Query (`PRESENZE_KEY`, `staleTime` 5 minuti) e
|
||||
`useRispostePresenze()` le espone come `presenze` e `tempi`.
|
||||
|
||||
`useSalvaPresenza()` non rilegge dopo la scrittura: aggiorna la cache a mano e deve tenere
|
||||
allineate **entrambe** le mappe. Sull'`upsert` la colonna `risposto_il` non viene inviata —
|
||||
è quello che la lascia intatta lato database sugli aggiornamenti — e la cache locale imita
|
||||
la stessa regola con `istanti[giocatoreId] ??= new Date().toISOString()`: si valorizza solo
|
||||
se manca. Chi tocca quella mutation deve preservare questi due dettagli, altrimenti ogni
|
||||
ripensamento farebbe ripartire il cronometro delle conferme.
|
||||
|
||||
---
|
||||
|
||||
## Da numero a card (`src/lib/serie.ts`)
|
||||
|
||||
`serieDefs` è l'unica fonte di verità della UI: label, descrizione, icona, traguardi e la
|
||||
funzione `valore(g)` che pesca il campo giusto dal `Giocatore`.
|
||||
|
||||
`statoSerie(def, g)` produce quello che serve a disegnare una card:
|
||||
|
||||
| Campo | Come si ricava |
|
||||
| ----------- | --------------------------------------------------------------------------- |
|
||||
| `valore` | `def.valore(g)` |
|
||||
| `prossimo` | primo traguardo **strettamente maggiore** del valore; `null` oltre l'ultimo |
|
||||
| `manca` | `prossimo - valore` (`0` se fuori scala) |
|
||||
| `progresso` | percentuale **dentro il livello corrente**, vedi sotto |
|
||||
| `messaggio` | microcopy, vedi sotto |
|
||||
|
||||
### Progresso
|
||||
|
||||
```
|
||||
progresso = round((valore - traguardoPrecedente) / (prossimo - traguardoPrecedente) * 100)
|
||||
```
|
||||
|
||||
La base è il traguardo già raggiunto, non zero. Con la vecchia formula (`valore / prossimo`)
|
||||
la barra **tornava indietro** ogni volta che se ne raggiungeva uno: a 2 allenamenti segnava
|
||||
67%, al terzo scendeva al 50%. Ora ogni traguardo apre un livello nuovo che riparte da 0% e
|
||||
sale fino a 100%, che si tocca solo restando fuori scala (`prossimo === null`).
|
||||
|
||||
Esempio con i traguardi degli allenamenti (3, 6, 10, 15):
|
||||
|
||||
| Valore | Prossimo | Base | Progresso |
|
||||
| ------ | -------- | ---- | --------- |
|
||||
| 0 | 3 | 0 | 0% |
|
||||
| 2 | 3 | 0 | 67% |
|
||||
| 3 | 6 | 3 | 0% |
|
||||
| 5 | 6 | 3 | 67% |
|
||||
| 15+ | — | — | 100% |
|
||||
|
||||
### Messaggi
|
||||
|
||||
`messaggioSerie()` valuta in quest'ordine, prima corrispondenza vince:
|
||||
|
||||
1. `valore === 0` → «Serie … azzerata: riparti dal prossimo.»
|
||||
2. `prossimo === null` → «Serie leggendaria: sei fuori scala!»
|
||||
3. `manca === 1` → «Manca solo una volta al prossimo traguardo!»
|
||||
4. `valore >= 5` → «Che continuità: ancora N e sali di livello.»
|
||||
5. altrimenti → «Bella partenza: N al prossimo traguardo.»
|
||||
|
||||
Nota: il caso 1 scatta anche per chi non ha **mai** iniziato, e dice "azzerata". Se dà
|
||||
fastidio, va distinto lì — il calcolo non sa differenziare "mai partito" da "appena rotto".
|
||||
|
||||
### Aggregatori
|
||||
|
||||
- `serieGiocatore(g)` — tutte le serie nell'ordine di `serieDefs`.
|
||||
- `serieMigliore(g)` — quella col valore più alto. `Array.sort` è stabile, quindi **a parità
|
||||
vince la prima definita in `serieDefs`**: con tutto a zero esce sempre "Allenamenti".
|
||||
|
||||
---
|
||||
|
||||
## Interfaccia (`src/components/crapp/SerieCard.tsx`)
|
||||
|
||||
- **`SerieGriglia`** — montata in `src/routes/profilo.tsx`, sezione "Serie di presenze". Una
|
||||
card per serie: icona (sfondo gradiente se `valore > 0`, grigio se a zero), label,
|
||||
descrizione, fiamma col numero, barra `Barra` e riga di testo `"valore/prossimo · messaggio"`
|
||||
(il prefisso `valore/prossimo` sparisce fuori scala).
|
||||
- **`SerieHome`** — riepilogo compatto: la serie migliore in evidenza più i tre numeri in
|
||||
griglia. Attualmente **non è montata in nessuna route**: è pronta ma non usata.
|
||||
|
||||
---
|
||||
|
||||
## Chi dipende dalle serie
|
||||
|
||||
Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
|
||||
|
||||
| Dove | Cosa | Soglie |
|
||||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||||
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
||||
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
|
||||
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
|
||||
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
||||
|
||||
---
|
||||
|
||||
## Costo
|
||||
|
||||
`useRosa()` ricalcola quattro serie per ogni giocatore attivo a ogni invalidazione della
|
||||
memo, e ogni serie scorre tutti gli eventi: **O(rosa × eventi)** per render memoizzato. Con
|
||||
una rosa e un calendario di squadra sono numeri irrisori. Le dipendenze della memo includono
|
||||
`eventi`, `mappaPresenze` e `tempi`: se in futuro qualcuna cambiasse identità a ogni render,
|
||||
il costo diventerebbe per-render e andrebbe stabilizzata a monte.
|
||||
|
||||
---
|
||||
|
||||
## Come modificare
|
||||
|
||||
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
|
||||
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
|
||||
- **Cambiare la regola di presenza** → il predicato dentro `serieConsecutiva()`.
|
||||
`infortunato` è già escluso a monte (congela la serie, non la azzera); valutare se
|
||||
allineare anche `contaPresenzeGiocatore()`, che oggi conta ancora `infortunato` come
|
||||
assenza ai fini statistici.
|
||||
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
|
||||
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
|
||||
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
|
||||
- **Cambiare la finestra delle conferme** → la costante `ORE_24`.
|
||||
- **Contare anche gli eventi extra-campo** (pizzate, `tipo: "evento"`) → il filtro in
|
||||
`eventiContanoPresenze()`, che però è condiviso col conteggio presenze: meglio un filtro
|
||||
dedicato passato a `serieSu()` che modificarlo lì.
|
||||
- **Aggiungere una quarta serie** → una voce in `serieDefs` (label, descrizione, icona,
|
||||
traguardi, `valore`), un campo nel tipo `Giocatore` (`crapp-data.ts`, più lo zero nel seed),
|
||||
il calcolo in `presenze.ts` e il collegamento in `useRosa()`. La UI non va toccata: griglia
|
||||
e home iterano su `serieDefs`.
|
||||
- **Mostrare il riepilogo in home** → `SerieHome` esiste già, basta montarla.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
**Le conferme rapide valgono solo da `m9` in avanti.** `risposto_il` non è ricostruibile a
|
||||
posteriori: le righe già esistenti al momento della migration hanno ereditato `aggiornato_il`,
|
||||
che è l'ultima modifica e non la prima risposta. Sui dati precedenti la serie è quindi
|
||||
un'approssimazione ottimistica.
|
||||
|
||||
**Un evento passato senza risposta azzera la serie**, come un'assenza dichiarata: chi non ha
|
||||
mai risposto ha serie a 0.
|
||||
|
||||
**L'ordinamento usa solo `data`, non `ora`.** Due eventi lo stesso giorno vengono processati
|
||||
nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non deterministico fra
|
||||
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
|
||||
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
|
||||
|
||||
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
||||
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
||||
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||||
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
||||
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
||||
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Istante di convocazione esplicito (invio notifica) da usare al posto di `creato_il` per le
|
||||
conferme.
|
||||
- Distinguere "serie mai iniziata" da "serie interrotta" nel microcopy.
|
||||
- Verificare che i badge e l'obiettivo "Continuità di squadra" si sblocchino davvero sui dati
|
||||
di stagione.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Modulo — Squadra
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
|
||||
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
|
||||
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
|
||||
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
|
||||
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
|
||||
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
|
||||
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
|
||||
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||
|
||||
---
|
||||
|
||||
## Due letture diverse, per non pagare due volte lo stesso costo
|
||||
|
||||
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
|
||||
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
|
||||
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni.
|
||||
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
|
||||
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
|
||||
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
|
||||
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
|
||||
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
|
||||
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
|
||||
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
|
||||
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
|
||||
(`obiettivi-squadra.md`).
|
||||
|
||||
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
|
||||
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
|
||||
dagli elenchi correnti.
|
||||
|
||||
## Gestione dati squadra (solo amministratore)
|
||||
|
||||
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
|
||||
|
||||
| Azione | Hook | Effetto |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
|
||||
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||
accesso con Google, per corrispondenza email
|
||||
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
|
||||
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
|
||||
dell'admin.
|
||||
|
||||
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
|
||||
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
|
||||
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
|
||||
all'amministratore.
|
||||
|
||||
## Classifica interna di Squadra
|
||||
|
||||
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
|
||||
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
|
||||
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
|
||||
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
|
||||
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
|
||||
pagella" per il criterio media voto, non sempre "presenze consecutive").
|
||||
|
||||
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
|
||||
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
|
||||
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i
|
||||
giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts`
|
||||
(`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita
|
||||
nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel
|
||||
Calendario per quei giocatori.
|
||||
2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
|
||||
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
|
||||
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
|
||||
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
|
||||
quella reale.
|
||||
@@ -1,389 +0,0 @@
|
||||
############
|
||||
# Docker compose override files to layer on top of docker-compose.yml.
|
||||
# Native docker compose COMPOSE_FILE: colon-separated list, base file first.
|
||||
# Manage with: ./run.sh config add|remove <name>
|
||||
#
|
||||
# Examples:
|
||||
# COMPOSE_FILE=docker-compose.yml
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
|
||||
#
|
||||
############
|
||||
COMPOSE_FILE=docker-compose.yml
|
||||
|
||||
|
||||
############
|
||||
# Secrets
|
||||
#
|
||||
# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING
|
||||
# THE CONTAINERS FOR THE FIRST TIME!
|
||||
#
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase
|
||||
#
|
||||
# To generate secrets and API keys:
|
||||
# 1. sh utils/generate-keys.sh
|
||||
# 2. sh utils/add-new-auth-keys.sh
|
||||
#
|
||||
############
|
||||
|
||||
# Postgres
|
||||
POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
|
||||
|
||||
# Legacy symmetric HS256 key
|
||||
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
|
||||
# Legacy API keys (HS256-signed JWTs)
|
||||
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
|
||||
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
|
||||
|
||||
# Asymmetric key pair (ES256) and opaque API keys
|
||||
#
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys
|
||||
#
|
||||
# To generate:
|
||||
# sh ./utils/add-new-auth-keys.sh
|
||||
#
|
||||
# Opaque API key for client-side use (anon role).
|
||||
SUPABASE_PUBLISHABLE_KEY=
|
||||
# Opaque API key for server-side use (service_role). Never expose in client code.
|
||||
SUPABASE_SECRET_KEY=
|
||||
# JSON array of signing JWKs (EC private + legacy symmetric).
|
||||
# Used by Auth.
|
||||
JWT_KEYS=
|
||||
# JWKS for token verification (EC public + legacy symmetric).
|
||||
# Used by PostgREST, Realtime, Storage to verify tokens.
|
||||
JWT_JWKS=
|
||||
|
||||
# Access to Dashboard
|
||||
DASHBOARD_USERNAME=supabase
|
||||
DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated
|
||||
|
||||
# Encryption key for securing Realtime and Supavisor communications.
|
||||
# (Must be at least 64 characters; generate with: openssl rand -base64 48)
|
||||
SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
|
||||
|
||||
# Encryption key used by Realtime for sensitive fields in the `_realtime` schema.
|
||||
# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`)
|
||||
REALTIME_DB_ENC_KEY=supabaserealtime
|
||||
|
||||
# Encryption key used by Supavisor for storing encrypted configuration.
|
||||
# (Must be exactly 32 characters; generate with: openssl rand -hex 16)
|
||||
VAULT_ENC_KEY=your-32-character-encryption-key
|
||||
|
||||
# Encryption key for securing connection strings used by Studio against postgres-meta.
|
||||
# (Must be at least 32 characters; generate with openssl rand -base64 24)
|
||||
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
|
||||
|
||||
# API token for log ingestion used by Logflare and Vector.
|
||||
# (Must be at least 32 characters; generate with openssl rand -base64 24)
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public
|
||||
# API token used for Logflare management operations. Never expose client-side.
|
||||
# (Must be at least 32 characters; generate with openssl rand -base64 24)
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
|
||||
|
||||
# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage.
|
||||
# (Generate with: openssl rand -hex 16)
|
||||
S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
|
||||
# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID.
|
||||
# (Generate with: openssl rand -hex 32)
|
||||
S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
|
||||
|
||||
|
||||
############
|
||||
# URLs - Configure hostnames below to reflect your actual domain name
|
||||
############
|
||||
|
||||
# Access to Dashboard and REST API
|
||||
SUPABASE_PUBLIC_URL=http://localhost:8000
|
||||
|
||||
# Full external URL of the Auth service, used to construct OAuth callbacks,
|
||||
# SAML endpoints, and email links
|
||||
API_EXTERNAL_URL=http://localhost:8000/auth/v1
|
||||
|
||||
# See also the Auth section below for Site URL and Redirect URLs configuration
|
||||
|
||||
|
||||
############
|
||||
# Database - Postgres configuration
|
||||
############
|
||||
|
||||
# Using default user (postgres)
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_DB=postgres
|
||||
|
||||
# Default configuration includes Supavisor exposing POSTGRES_PORT
|
||||
# Postgres uses POSTGRES_PORT inside the container
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
|
||||
############
|
||||
# Database pooler
|
||||
############
|
||||
|
||||
# Self-hosted Supabase uses Supavisor as the default database pooler.
|
||||
# If you use the PgBouncer docker-compose override, Supavisor is disabled
|
||||
# and the pooler settings below are used to configure PgBouncer instead.
|
||||
#
|
||||
# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION,
|
||||
# POSTGRES_PORT is used for session mode pooling
|
||||
# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION.
|
||||
#
|
||||
# Port to use for transaction mode pooling connections
|
||||
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||
|
||||
# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool
|
||||
POOLER_DEFAULT_POOL_SIZE=20
|
||||
|
||||
# Maximum number of client connections Supavisor or PgBouncer accepts per pool
|
||||
POOLER_MAX_CLIENT_CONN=100
|
||||
|
||||
# Unique Supavisor tenant identifier
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
|
||||
POOLER_TENANT_ID=your-tenant-id
|
||||
|
||||
# Pool size for internal metadata storage used by Supavisor
|
||||
# This is separate from client connections and used only by Supavisor itself
|
||||
POOLER_DB_POOL_SIZE=5
|
||||
|
||||
|
||||
############
|
||||
# Studio - Configuration for the Dashboard
|
||||
############
|
||||
|
||||
STUDIO_DEFAULT_ORGANIZATION=Default Organization
|
||||
STUDIO_DEFAULT_PROJECT=Default Project
|
||||
|
||||
# Add your OpenAI API key to enable AI Assistant
|
||||
OPENAI_API_KEY=sk-proj-xxxxxxxx
|
||||
|
||||
|
||||
############
|
||||
# Auth - Configuration for the authentication server
|
||||
############
|
||||
|
||||
## General settings
|
||||
|
||||
# Equivalent to "Site URL" and "Redirect URLs" platform configuration options
|
||||
# Documentation: https://supabase.com/docs/guides/auth/redirect-urls
|
||||
SITE_URL=http://localhost:3000
|
||||
ADDITIONAL_REDIRECT_URLS=
|
||||
|
||||
JWT_EXPIRY=3600
|
||||
DISABLE_SIGNUP=false
|
||||
|
||||
## Mailer Config
|
||||
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
|
||||
MAILER_URLPATHS_INVITE="/auth/v1/verify"
|
||||
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
|
||||
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
|
||||
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||
SMTP_ADMIN_EMAIL=admin@example.com
|
||||
SMTP_HOST=supabase-mail
|
||||
SMTP_PORT=2500
|
||||
SMTP_USER=fake_mail_user
|
||||
SMTP_PASS=fake_mail_password
|
||||
SMTP_SENDER_NAME=fake_sender
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
|
||||
## Phone auth
|
||||
ENABLE_PHONE_SIGNUP=true
|
||||
ENABLE_PHONE_AUTOCONFIRM=true
|
||||
|
||||
## OAuth / Social login providers
|
||||
|
||||
# Uncomment and fill in the providers you want to enable.
|
||||
# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth
|
||||
# GOOGLE_ENABLED=false
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_SECRET=
|
||||
|
||||
# GITHUB_ENABLED=false
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_SECRET=
|
||||
|
||||
# AZURE_ENABLED=false
|
||||
# AZURE_CLIENT_ID=
|
||||
# AZURE_SECRET=
|
||||
|
||||
# Phone / SMS provider configuration
|
||||
# Uncomment to configure SMS delivery for phone auth and phone MFA.
|
||||
# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa
|
||||
# SMS_PROVIDER=twilio
|
||||
# SMS_OTP_EXP=60
|
||||
# SMS_OTP_LENGTH=6
|
||||
# SMS_MAX_FREQUENCY=60s
|
||||
# SMS_TEMPLATE=Your code is {{ .Code }}
|
||||
|
||||
# SMS_TWILIO_ACCOUNT_SID=
|
||||
# SMS_TWILIO_AUTH_TOKEN=
|
||||
# SMS_TWILIO_MESSAGE_SERVICE_SID=
|
||||
|
||||
# Test OTP: map phone numbers to fixed OTP codes for development
|
||||
# Format: phone1:code1,phone2:code2
|
||||
# SMS_TEST_OTP=
|
||||
|
||||
# Multi-factor authentication (MFA)
|
||||
# Uncomment to change MFA defaults.
|
||||
# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml
|
||||
|
||||
# App Authenticator (TOTP) - enabled by default
|
||||
# MFA_TOTP_ENROLL_ENABLED=true
|
||||
# MFA_TOTP_VERIFY_ENABLED=true
|
||||
|
||||
# Phone MFA - disabled by default (opt-in)
|
||||
# MFA_PHONE_ENROLL_ENABLED=false
|
||||
# MFA_PHONE_VERIFY_ENABLED=false
|
||||
|
||||
# Maximum MFA factors a user can enroll
|
||||
# MFA_MAX_ENROLLED_FACTORS=10
|
||||
|
||||
## SAML SSO
|
||||
|
||||
# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso
|
||||
|
||||
# SAML_ENABLED=true
|
||||
# SAML_PRIVATE_KEY=<your-base64-encoded-private-key>
|
||||
|
||||
# Optional: accept encrypted SAML assertions from IdPs (default: false)
|
||||
# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false
|
||||
|
||||
# Optional: how long relay state tokens remain valid (default: 2m0s)
|
||||
# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s
|
||||
|
||||
# Optional: override the SAML entity ID / ACS base URL
|
||||
# Defaults to API_EXTERNAL_URL if not set
|
||||
# SAML_EXTERNAL_URL=https://supabase.example.com:8000/auth/v1
|
||||
|
||||
# Optional: rate limit on the ACS endpoint (requests per second, default: 15)
|
||||
# SAML_RATE_LIMIT_ASSERTION=15
|
||||
|
||||
|
||||
############
|
||||
# Storage - Configuration for Storage
|
||||
############
|
||||
|
||||
# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and
|
||||
# refer to the documentation at:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-s3
|
||||
# to learn how to configure the S3 protocol endpoint
|
||||
|
||||
# S3 bucket when using S3 backend, directory name when using 'file'
|
||||
GLOBAL_S3_BUCKET=stub
|
||||
|
||||
# Used for S3 protocol endpoint configuration
|
||||
REGION=stub
|
||||
|
||||
# Used by MinIO when added via:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
|
||||
MINIO_ROOT_USER=supa-storage
|
||||
# Root administrator password for the RustFS or MinIO server.
|
||||
# (Must be 8+ characters; generate with: openssl rand -hex 16)
|
||||
MINIO_ROOT_PASSWORD=secret1234
|
||||
|
||||
# Equivalent to project_ref as described here:
|
||||
# https://supabase.com/docs/guides/storage/s3/authentication#session-token
|
||||
STORAGE_TENANT_ID=stub
|
||||
|
||||
|
||||
############
|
||||
# Functions - Configuration for Edge functions
|
||||
############
|
||||
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-functions
|
||||
|
||||
# NOTE: VERIFY_JWT applies to all functions
|
||||
FUNCTIONS_VERIFY_JWT=false
|
||||
|
||||
|
||||
############
|
||||
# API - Configuration for PostgREST
|
||||
############
|
||||
|
||||
# Postgres schemas exposed via the REST API
|
||||
PGRST_DB_SCHEMAS=public,graphql_public
|
||||
|
||||
# Max number of rows returned by a request
|
||||
PGRST_DB_MAX_ROWS=1000
|
||||
|
||||
# Extra schemas added to the search_path of every request
|
||||
PGRST_DB_EXTRA_SEARCH_PATH=public
|
||||
|
||||
|
||||
############
|
||||
# Logs and Analytics
|
||||
############
|
||||
|
||||
## Vector log collection and routing
|
||||
|
||||
# Docker socket location - required for proper Vector operation
|
||||
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
|
||||
# For Podman use the following:
|
||||
# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock
|
||||
|
||||
## Analytics (Logflare)
|
||||
|
||||
# Check the LOGFLARE_* access token configuration _above_.
|
||||
# If Logflare has to be externally exposed - configure securely!
|
||||
|
||||
# Google Cloud Project details
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/reference/self-hosting-analytics/introduction
|
||||
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
|
||||
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
|
||||
|
||||
|
||||
############
|
||||
# API gateway
|
||||
############
|
||||
|
||||
# Host port the API gateway (Envoy by default) listens on.
|
||||
API_GW_HTTP_PORT=8000
|
||||
|
||||
# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is
|
||||
# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for
|
||||
# API_GW_HTTP_PORT so existing .env files continue to work.
|
||||
KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
# Used internally by the API gateway - DO NOT use in any client or server code.
|
||||
# Pre-signed ES256 JWT "API key" for anon role.
|
||||
ANON_KEY_ASYMMETRIC=
|
||||
# Pre-signed ES256 JWT "API key" for service_role.
|
||||
SERVICE_ROLE_KEY_ASYMMETRIC=
|
||||
|
||||
|
||||
############
|
||||
# imgproxy
|
||||
############
|
||||
|
||||
# Enable webp support
|
||||
IMGPROXY_AUTO_WEBP=true
|
||||
|
||||
|
||||
############
|
||||
# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt
|
||||
############
|
||||
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
|
||||
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
||||
|
||||
# Domain name for the proxy (must point to your server)
|
||||
PROXY_DOMAIN=your-domain.example.com
|
||||
|
||||
# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN).
|
||||
# This should be a valid email, not a placeholder (otherwise Certbot may fail to start).
|
||||
CERTBOT_EMAIL=admin@example.com
|
||||
@@ -1,402 +0,0 @@
|
||||
# TEMPLATE DI PRODUZIONE — non usare i valori cosi' come sono.
|
||||
#
|
||||
# 1. Copia questo file in .env: cp .env.production.example .env
|
||||
# 2. Rigenera TUTTI i secret (non riusare quelli di sviluppo):
|
||||
# sh utils/generate-keys.sh --update-env
|
||||
# 3. Sostituisci i placeholder tuodominio.it con il dominio reale (un solo hostname: le API
|
||||
# Supabase sono servite sotto /api, instradate per path da Caddy — vedi Caddyfile.example).
|
||||
# Con No-IP gratuito, es. crapp.ddns.net per tutto.
|
||||
# 4. Imposta una DASHBOARD_PASSWORD robusta e, se servono email
|
||||
# (reset password, inviti), un SMTP reale al posto di quello finto.
|
||||
# 5. Avvia con l'override che non espone porte pubblicamente:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
#
|
||||
############
|
||||
# Docker compose override files to layer on top of docker-compose.yml.
|
||||
# Native docker compose COMPOSE_FILE: colon-separated list, base file first.
|
||||
# Manage with: ./run.sh config add|remove <name>
|
||||
#
|
||||
# Examples:
|
||||
# COMPOSE_FILE=docker-compose.yml
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
|
||||
#
|
||||
############
|
||||
COMPOSE_FILE=docker-compose.yml
|
||||
|
||||
|
||||
############
|
||||
# Secrets
|
||||
#
|
||||
# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING
|
||||
# THE CONTAINERS FOR THE FIRST TIME!
|
||||
#
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase
|
||||
#
|
||||
# To generate secrets and API keys:
|
||||
# 1. sh utils/generate-keys.sh
|
||||
# 2. sh utils/add-new-auth-keys.sh
|
||||
#
|
||||
############
|
||||
|
||||
# Postgres
|
||||
POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
|
||||
|
||||
# Legacy symmetric HS256 key
|
||||
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
|
||||
# Legacy API keys (HS256-signed JWTs)
|
||||
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
|
||||
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
|
||||
|
||||
# Asymmetric key pair (ES256) and opaque API keys
|
||||
#
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys
|
||||
#
|
||||
# To generate:
|
||||
# sh ./utils/add-new-auth-keys.sh
|
||||
#
|
||||
# Opaque API key for client-side use (anon role).
|
||||
SUPABASE_PUBLISHABLE_KEY=
|
||||
# Opaque API key for server-side use (service_role). Never expose in client code.
|
||||
SUPABASE_SECRET_KEY=
|
||||
# JSON array of signing JWKs (EC private + legacy symmetric).
|
||||
# Used by Auth.
|
||||
JWT_KEYS=
|
||||
# JWKS for token verification (EC public + legacy symmetric).
|
||||
# Used by PostgREST, Realtime, Storage to verify tokens.
|
||||
JWT_JWKS=
|
||||
|
||||
# Access to Dashboard
|
||||
DASHBOARD_USERNAME=supabase
|
||||
DASHBOARD_PASSWORD=CAMBIAMI-password-robusta
|
||||
|
||||
# Encryption key for securing Realtime and Supavisor communications.
|
||||
# (Must be at least 64 characters; generate with: openssl rand -base64 48)
|
||||
SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
|
||||
|
||||
# Encryption key used by Realtime for sensitive fields in the `_realtime` schema.
|
||||
# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`)
|
||||
REALTIME_DB_ENC_KEY=supabaserealtime
|
||||
|
||||
# Encryption key used by Supavisor for storing encrypted configuration.
|
||||
# (Must be exactly 32 characters; generate with: openssl rand -hex 16)
|
||||
VAULT_ENC_KEY=your-32-character-encryption-key
|
||||
|
||||
# Encryption key for securing connection strings used by Studio against postgres-meta.
|
||||
# (Must be at least 32 characters; generate with openssl rand -base64 24)
|
||||
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
|
||||
|
||||
# API token for log ingestion used by Logflare and Vector.
|
||||
# (Must be at least 32 characters; generate with openssl rand -base64 24)
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public
|
||||
# API token used for Logflare management operations. Never expose client-side.
|
||||
# (Must be at least 32 characters; generate with openssl rand -base64 24)
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
|
||||
|
||||
# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage.
|
||||
# (Generate with: openssl rand -hex 16)
|
||||
S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
|
||||
# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID.
|
||||
# (Generate with: openssl rand -hex 32)
|
||||
S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
|
||||
|
||||
|
||||
############
|
||||
# URLs - Configure hostnames below to reflect your actual domain name
|
||||
############
|
||||
|
||||
# Access to Dashboard and REST API (dietro Caddy: /api/* -> questo gateway, path stripped)
|
||||
SUPABASE_PUBLIC_URL=https://tuodominio.it/api
|
||||
|
||||
# Full external URL of the Auth service, used to construct OAuth callbacks,
|
||||
# SAML endpoints, and email links
|
||||
API_EXTERNAL_URL=https://tuodominio.it/api/auth/v1
|
||||
|
||||
# See also the Auth section below for Site URL and Redirect URLs configuration
|
||||
|
||||
|
||||
############
|
||||
# Database - Postgres configuration
|
||||
############
|
||||
|
||||
# Using default user (postgres)
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_DB=postgres
|
||||
|
||||
# Default configuration includes Supavisor exposing POSTGRES_PORT
|
||||
# Postgres uses POSTGRES_PORT inside the container
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
|
||||
POSTGRES_PORT=5432 # non esporre su internet, vedi docker-compose.prod.yml
|
||||
|
||||
|
||||
############
|
||||
# Database pooler
|
||||
############
|
||||
|
||||
# Self-hosted Supabase uses Supavisor as the default database pooler.
|
||||
# If you use the PgBouncer docker-compose override, Supavisor is disabled
|
||||
# and the pooler settings below are used to configure PgBouncer instead.
|
||||
#
|
||||
# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION,
|
||||
# POSTGRES_PORT is used for session mode pooling
|
||||
# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION.
|
||||
#
|
||||
# Port to use for transaction mode pooling connections
|
||||
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||
|
||||
# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool
|
||||
POOLER_DEFAULT_POOL_SIZE=20
|
||||
|
||||
# Maximum number of client connections Supavisor or PgBouncer accepts per pool
|
||||
POOLER_MAX_CLIENT_CONN=100
|
||||
|
||||
# Unique Supavisor tenant identifier
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
|
||||
POOLER_TENANT_ID=your-tenant-id
|
||||
|
||||
# Pool size for internal metadata storage used by Supavisor
|
||||
# This is separate from client connections and used only by Supavisor itself
|
||||
POOLER_DB_POOL_SIZE=5
|
||||
|
||||
|
||||
############
|
||||
# Studio - Configuration for the Dashboard
|
||||
############
|
||||
|
||||
STUDIO_DEFAULT_ORGANIZATION=Default Organization
|
||||
STUDIO_DEFAULT_PROJECT=Default Project
|
||||
|
||||
# Add your OpenAI API key to enable AI Assistant
|
||||
OPENAI_API_KEY=sk-proj-xxxxxxxx
|
||||
|
||||
|
||||
############
|
||||
# Auth - Configuration for the authentication server
|
||||
############
|
||||
|
||||
## General settings
|
||||
|
||||
# Equivalent to "Site URL" and "Redirect URLs" platform configuration options
|
||||
# Documentation: https://supabase.com/docs/guides/auth/redirect-urls
|
||||
SITE_URL=https://tuodominio.it
|
||||
ADDITIONAL_REDIRECT_URLS=
|
||||
|
||||
JWT_EXPIRY=3600
|
||||
DISABLE_SIGNUP=false
|
||||
|
||||
## Mailer Config
|
||||
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
|
||||
MAILER_URLPATHS_INVITE="/auth/v1/verify"
|
||||
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
|
||||
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
|
||||
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||
SMTP_ADMIN_EMAIL=admin@example.com
|
||||
SMTP_HOST=supabase-mail
|
||||
SMTP_PORT=2500
|
||||
SMTP_USER=fake_mail_user
|
||||
SMTP_PASS=fake_mail_password
|
||||
SMTP_SENDER_NAME=fake_sender
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
|
||||
## Phone auth
|
||||
ENABLE_PHONE_SIGNUP=true
|
||||
ENABLE_PHONE_AUTOCONFIRM=true
|
||||
|
||||
## OAuth / Social login providers
|
||||
|
||||
# Uncomment and fill in the providers you want to enable.
|
||||
# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth
|
||||
# GOOGLE_ENABLED=false
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_SECRET=
|
||||
|
||||
# GITHUB_ENABLED=false
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_SECRET=
|
||||
|
||||
# AZURE_ENABLED=false
|
||||
# AZURE_CLIENT_ID=
|
||||
# AZURE_SECRET=
|
||||
|
||||
# Phone / SMS provider configuration
|
||||
# Uncomment to configure SMS delivery for phone auth and phone MFA.
|
||||
# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa
|
||||
# SMS_PROVIDER=twilio
|
||||
# SMS_OTP_EXP=60
|
||||
# SMS_OTP_LENGTH=6
|
||||
# SMS_MAX_FREQUENCY=60s
|
||||
# SMS_TEMPLATE=Your code is {{ .Code }}
|
||||
|
||||
# SMS_TWILIO_ACCOUNT_SID=
|
||||
# SMS_TWILIO_AUTH_TOKEN=
|
||||
# SMS_TWILIO_MESSAGE_SERVICE_SID=
|
||||
|
||||
# Test OTP: map phone numbers to fixed OTP codes for development
|
||||
# Format: phone1:code1,phone2:code2
|
||||
# SMS_TEST_OTP=
|
||||
|
||||
# Multi-factor authentication (MFA)
|
||||
# Uncomment to change MFA defaults.
|
||||
# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml
|
||||
|
||||
# App Authenticator (TOTP) - enabled by default
|
||||
# MFA_TOTP_ENROLL_ENABLED=true
|
||||
# MFA_TOTP_VERIFY_ENABLED=true
|
||||
|
||||
# Phone MFA - disabled by default (opt-in)
|
||||
# MFA_PHONE_ENROLL_ENABLED=false
|
||||
# MFA_PHONE_VERIFY_ENABLED=false
|
||||
|
||||
# Maximum MFA factors a user can enroll
|
||||
# MFA_MAX_ENROLLED_FACTORS=10
|
||||
|
||||
## SAML SSO
|
||||
|
||||
# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso
|
||||
|
||||
# SAML_ENABLED=true
|
||||
# SAML_PRIVATE_KEY=<your-base64-encoded-private-key>
|
||||
|
||||
# Optional: accept encrypted SAML assertions from IdPs (default: false)
|
||||
# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false
|
||||
|
||||
# Optional: how long relay state tokens remain valid (default: 2m0s)
|
||||
# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s
|
||||
|
||||
# Optional: override the SAML entity ID / ACS base URL
|
||||
# Defaults to API_EXTERNAL_URL if not set
|
||||
# SAML_EXTERNAL_URL=https://supabase.example.com:8000/auth/v1
|
||||
|
||||
# Optional: rate limit on the ACS endpoint (requests per second, default: 15)
|
||||
# SAML_RATE_LIMIT_ASSERTION=15
|
||||
|
||||
|
||||
############
|
||||
# Storage - Configuration for Storage
|
||||
############
|
||||
|
||||
# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and
|
||||
# refer to the documentation at:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-s3
|
||||
# to learn how to configure the S3 protocol endpoint
|
||||
|
||||
# S3 bucket when using S3 backend, directory name when using 'file'
|
||||
GLOBAL_S3_BUCKET=stub
|
||||
|
||||
# Used for S3 protocol endpoint configuration
|
||||
REGION=stub
|
||||
|
||||
# Used by MinIO when added via:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
|
||||
MINIO_ROOT_USER=supa-storage
|
||||
# Root administrator password for the RustFS or MinIO server.
|
||||
# (Must be 8+ characters; generate with: openssl rand -hex 16)
|
||||
MINIO_ROOT_PASSWORD=secret1234
|
||||
|
||||
# Equivalent to project_ref as described here:
|
||||
# https://supabase.com/docs/guides/storage/s3/authentication#session-token
|
||||
STORAGE_TENANT_ID=stub
|
||||
|
||||
|
||||
############
|
||||
# Functions - Configuration for Edge functions
|
||||
############
|
||||
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-functions
|
||||
|
||||
# NOTE: VERIFY_JWT applies to all functions
|
||||
FUNCTIONS_VERIFY_JWT=false
|
||||
|
||||
|
||||
############
|
||||
# API - Configuration for PostgREST
|
||||
############
|
||||
|
||||
# Postgres schemas exposed via the REST API
|
||||
PGRST_DB_SCHEMAS=public,graphql_public
|
||||
|
||||
# Max number of rows returned by a request
|
||||
PGRST_DB_MAX_ROWS=1000
|
||||
|
||||
# Extra schemas added to the search_path of every request
|
||||
PGRST_DB_EXTRA_SEARCH_PATH=public
|
||||
|
||||
|
||||
############
|
||||
# Logs and Analytics
|
||||
############
|
||||
|
||||
## Vector log collection and routing
|
||||
|
||||
# Docker socket location - required for proper Vector operation
|
||||
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
|
||||
# For Podman use the following:
|
||||
# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock
|
||||
|
||||
## Analytics (Logflare)
|
||||
|
||||
# Check the LOGFLARE_* access token configuration _above_.
|
||||
# If Logflare has to be externally exposed - configure securely!
|
||||
|
||||
# Google Cloud Project details
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/reference/self-hosting-analytics/introduction
|
||||
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
|
||||
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
|
||||
|
||||
|
||||
############
|
||||
# API gateway
|
||||
############
|
||||
|
||||
# Host port the API gateway (Envoy by default) listens on.
|
||||
API_GW_HTTP_PORT=8000
|
||||
|
||||
# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is
|
||||
# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for
|
||||
# API_GW_HTTP_PORT so existing .env files continue to work.
|
||||
KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
# Used internally by the API gateway - DO NOT use in any client or server code.
|
||||
# Pre-signed ES256 JWT "API key" for anon role.
|
||||
ANON_KEY_ASYMMETRIC=
|
||||
# Pre-signed ES256 JWT "API key" for service_role.
|
||||
SERVICE_ROLE_KEY_ASYMMETRIC=
|
||||
|
||||
|
||||
############
|
||||
# imgproxy
|
||||
############
|
||||
|
||||
# Enable webp support
|
||||
IMGPROXY_AUTO_WEBP=true
|
||||
|
||||
|
||||
############
|
||||
# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt
|
||||
############
|
||||
|
||||
# Documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
|
||||
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
||||
|
||||
# Domain name for the proxy (must point to your server)
|
||||
PROXY_DOMAIN=your-domain.example.com
|
||||
|
||||
# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN).
|
||||
# This should be a valid email, not a placeholder (otherwise Certbot may fail to start).
|
||||
CERTBOT_EMAIL=admin@example.com
|
||||
@@ -1,17 +0,0 @@
|
||||
* text=auto
|
||||
|
||||
*.md eol=lf
|
||||
|
||||
*.env eol=lf
|
||||
.env.example eol=lf
|
||||
|
||||
*.sh eol=lf
|
||||
*.sql eol=lf
|
||||
*.yml eol=lf
|
||||
*.yaml eol=lf
|
||||
*.ts eol=lf
|
||||
*.exs eol=lf
|
||||
*.conf eol=lf
|
||||
*.tpl eol=lf
|
||||
Caddyfile eol=lf
|
||||
Dockerfile* eol=lf
|
||||
@@ -1,16 +0,0 @@
|
||||
volumes/db/data
|
||||
volumes/storage
|
||||
volumes/snippets
|
||||
volumes/functions/**
|
||||
!volumes/functions/deno.json*
|
||||
!volumes/functions/main/
|
||||
volumes/functions/main/**
|
||||
!volumes/functions/main/index.ts
|
||||
!volumes/functions/hello/
|
||||
volumes/functions/hello/**
|
||||
!volumes/functions/hello/index.ts
|
||||
.env
|
||||
test.http
|
||||
docker-compose.override.yml
|
||||
.supabase-version
|
||||
backups
|
||||
@@ -1,676 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Supabase self-hosted Docker configuration.
|
||||
|
||||
Changes are grouped by service rather than by change type. See [versions.md](./versions.md) for complete image version history and rollback information.
|
||||
|
||||
See per-service updates below for details. Only the most important changes relevant to [self-hosted Supabase](https://supabase.com/docs/guides/self-hosting) are included here. For the full list of changes, refer to the release notes and changelogs of each individual service.
|
||||
|
||||
**Note:** Configuration updates marked with "requires [...] update" are already included in the latest version of the repository. Pull the latest changes or refer to the linked PR for manual updates. After updating `docker-compose.yml`, pull the latest images and recreate containers - use `docker compose pull && docker compose down && docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.8.0) - 2026-08-11
|
||||
|
||||
⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below:
|
||||
- Envoy is now the default API gateway, replacing Kong. Kong stays available as an opt-in override with `sh run.sh config add kong`. See the [heads-up discussion](https://github.com/orgs/supabase/discussions/48048) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
|
||||
### Configuration
|
||||
- ⚠️ Added `API_GW_HTTP_PORT` (falls back to `KONG_HTTP_PORT`; requires `.env` and `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
|
||||
### Documentation
|
||||
- Updated several self-hosting how-to guides to reflect the current configuration - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
- Updated architecture diagram for self-hosted Supabase - PR [#48763](https://github.com/supabase/supabase/pull/48763)
|
||||
|
||||
### Utils and tests
|
||||
- Updated `tests/` to match the API gateway switch to Envoy - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
|
||||
### API gateway
|
||||
- ⚠️ Changed the default API gateway from Kong to Envoy; the `kong` service is now `api-gw` (requires `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
- Changed `docker-compose.envoy.yml` to a no-op shim now that Envoy is the default (requires `docker-compose.envoy.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
- Added an optional override for Kong (requires new `docker-compose.kong.yml`) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
- Updated the Caddy and nginx reverse proxies to forward to `api-gw` (requires `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/proxy/caddy/Caddyfile`, and `volumes/proxy/nginx/supabase-nginx.conf.tpl` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
|
||||
|
||||
---
|
||||
|
||||
## [0.7.2](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.2) - 2026-08-04
|
||||
|
||||
### Utils and tests
|
||||
- Fixed `update.sh` overwriting itself during an update; the new `update.sh` is now staged as `update.sh.new` for review instead of replacing the running script - PR [#48690](https://github.com/supabase/supabase/pull/48690)
|
||||
- `update.sh` now fetches only the `docker/` directory (partial clone), making updates substantially faster and lighter - PR [#48690](https://github.com/supabase/supabase/pull/48690)
|
||||
|
||||
---
|
||||
|
||||
## [0.7.1](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.1) - 2026-08-03
|
||||
|
||||
### Configuration
|
||||
- Added `SUPABASE_JWKS` configuration for Edge Functions to `docker-compose.yml` - PR [#45635](https://github.com/supabase/supabase/pull/45635)
|
||||
- Added `upgrades.json` - a version-keyed manifest to gate breaking changes (required for `update.sh`) - PR [#47851](https://github.com/supabase/supabase/pull/47851)
|
||||
- Updated `.gitignore` (required for `update.sh`)
|
||||
|
||||
### Documentation
|
||||
- Added new how-to guides ([Custom Postgres Extensions](https://supabase.com/docs/guides/self-hosting/custom-postgres-extensions) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating)) - PR [#48203](https://github.com/supabase/supabase/pull/48203), PR [#48535](https://github.com/supabase/supabase/pull/48535)
|
||||
|
||||
### Utils and tests
|
||||
- Added base version stamp to `setup.sh` (saved in `.supabase-version`, required for `update.sh`) - PR [#47848](https://github.com/supabase/supabase/pull/47848)
|
||||
- Added `update.sh`. Refer to [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#47851](https://github.com/supabase/supabase/pull/47851)
|
||||
- Added `SUPABASE_JWKS` configuration for Edge Functions to `utils/add-new-auth-keys.sh` - PR [#45635](https://github.com/supabase/supabase/pull/45635)
|
||||
- Updated `tests/test-s3.sh` and `test-s3-backend.sh` - PR [#48500](https://github.com/supabase/supabase/pull/48500)
|
||||
|
||||
### API gateway
|
||||
- Updated Kong to `3.9.3`
|
||||
- Added `KONG_DNS_VALID_TTL` configuration environment variable (requires `docker-compose.yml` update) - PR [#47846](https://github.com/supabase/supabase/pull/47846)
|
||||
- Updated Envoy to `1.39.0` (requires `docker-compose.envoy.yml` update)
|
||||
- Updated [nginx-certbot](https://github.com/JonasAlfredsson/docker-nginx-certbot) to `6.2.0-nginx1.31.3` (requires `docker-compose.nginx.yml` update)
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.08.03-sha-022b374`
|
||||
- Fixed URL generation for Edge Functions - PR [#47861](https://github.com/supabase/supabase/pull/47861) (via [@7ttp](https://github.com/7ttp))
|
||||
- Fixed the Logs tab visibility in **Auth > Users** - PR [#48122](https://github.com/supabase/supabase/pull/48122) (via [@luizfelmach](https://github.com/luizfelmach/))
|
||||
|
||||
### Storage
|
||||
- Changed RustFS image to `1.0.0-beta.11` temporarily (requires `docker-compose.rustfs.yml` update) - PR [#48500](https://github.com/supabase/supabase/pull/48500)
|
||||
|
||||
### Edge Runtime
|
||||
- Changed JWKS configuration mechanism for main worker (requires `docker-compose.yml` and `volumes/functions/main/index.ts` update) - PR [#45635](https://github.com/supabase/supabase/pull/45635)
|
||||
|
||||
---
|
||||
|
||||
## [0.7.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.0) - 2026-07-07
|
||||
|
||||
⚠️ **Note:** This update contains **breaking changes**:
|
||||
- Access to the OpenAPI spec at `/rest/v1/` via the anon (publishable) key has been removed. Requests using the service role or new secret API key are unaffected, and data access via `/rest/v1/your_table` or any client library continues to work as-is. See discussion [#42949](https://github.com/orgs/supabase/discussions/42949)
|
||||
- `API_EXTERNAL_URL` has been updated to include the `/auth/v1` path prefix (e.g. `http://localhost:8000/auth/v1`), aligning self-hosted with the platform and CLI. This makes custom OAuth providers work out of the box and moves SAML SSO endpoints to `/auth/v1/sso/saml/*`. See discussion [#47093](https://github.com/orgs/supabase/discussions/47093) and PR [#47640](https://github.com/supabase/supabase/pull/47640)
|
||||
|
||||
### Configuration
|
||||
- ⚠️ Added `KONG_ROUTER_FLAVOR` to the compose configuration for Kong (requires `docker-compose.yml` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462)
|
||||
- ⚠️ Changed the default `API_EXTERNAL_URL` in `.env.example` to contain `/auth/v1` - PR [#47640](https://github.com/supabase/supabase/pull/47640)
|
||||
- ⚠️ Changed the default `PGRST_DB_SCHEMAS` to `public,graphql_public` in `.env.example` to avoid exposing `storage` (a protected schema)
|
||||
|
||||
### Documentation
|
||||
- Minor updates to the how-to guides following the configuration changes
|
||||
|
||||
### Utils and tests
|
||||
- Updated `setup.sh` to match the new `API_EXTERNAL_URL` configuration
|
||||
- Updated `utils/generate-keys.sh` to also generate a unique `REALTIME_DB_ENC_KEY`
|
||||
- Updated `tests/test-self-hosted.sh` and `tests/test-auth-keys.sh` to reflect the changes in the API gateway configuration
|
||||
|
||||
### API gateway
|
||||
- ⚠️ Updated Kong and Envoy configuration to restrict access to PostgREST `/rest/v1/` (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462) (via [@luizfelmach](https://github.com/luizfelmach/))
|
||||
- ⚠️ Updated Kong and Envoy configuration to match the new `/auth/v1/sso` routing for SAML SSO (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.07.07-sha-a6a04f2`
|
||||
- Fixed the local SQL snippets not being shown in the SQL Editor - PR [#47403](https://github.com/supabase/supabase/pull/47403), PR [#47409](https://github.com/supabase/supabase/pull/47409)
|
||||
- Fixed the exposed schemas and tables UI to properly reflect non-platform configuration (**Data API > Settings**) - PR [#47511](https://github.com/supabase/supabase/pull/47511)
|
||||
- Fixed the behavior of the type generator (**Data API > Docs**) - PR [#47577](https://github.com/supabase/supabase/pull/47577)
|
||||
|
||||
### Auth
|
||||
- ⚠️ Changed Auth configuration placeholders to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
|
||||
- ⚠️ Changed `GOTRUE_JWT_ISSUER` to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
|
||||
|
||||
### Realtime
|
||||
- ⚠️ Added a new configuration variable `REALTIME_DB_ENC_KEY` for Realtime with a fallback to the default value (requires `docker-compose.yml` update) - PR [#46021](https://github.com/supabase/supabase/pull/46021)
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.6.0) - 2026-06-17
|
||||
|
||||
⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below:
|
||||
- **Postgres 17 is now the default**. Do not start Postgres 17 on an existing Postgres 15 data directory. See the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) guide. Check the **Configuration** and **Postgres** sections for additional information
|
||||
- API gateway configuration includes a **security fix** for Realtime routes - it is **strongly recommended** to add this update to any self-hosted Supabase instance running Realtime
|
||||
- Studio and Postgres Meta configuration now use `postgres` and not `supabase_admin` to connect to Postgres
|
||||
|
||||
### Configuration
|
||||
- ⚠️ Changed the default Postgres image to `supabase/postgres:17.6.1.136` - PR [#46981](https://github.com/supabase/supabase/pull/46981)
|
||||
- ⚠️ Added `docker-compose.pg15.yml` - for deployments not yet upgraded, and as the rollback target for `utils/upgrade-pg17.sh` - PR [#46981](https://github.com/supabase/supabase/pull/46981)
|
||||
- Updated `docker-compose.pg17.yml` to match the new default - PR [#46981](https://github.com/supabase/supabase/pull/46981)
|
||||
|
||||
### Documentation
|
||||
- Updated the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) how-to - PR [#46989](https://github.com/supabase/supabase/pull/46989)
|
||||
- Updated the [New API Keys](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) and [Envoy API Gateway](https://supabase.com/docs/guides/self-hosting/self-hosted-envoy) how-to guides - PR [#46856](https://github.com/supabase/supabase/pull/46856)
|
||||
- Updated [CONFIG.md](CONFIG.md) - PR [#47022](https://github.com/supabase/supabase/pull/47022)
|
||||
|
||||
### Utils and tests
|
||||
- Updated `utils/upgrade-pg17.sh` (bumped Postgres image, added additional migrations), and `tests/test-pg17-upgrade.sh` (added tests for pg_cron) - PR [#46981](https://github.com/supabase/supabase/pull/46981)
|
||||
- Updated `tests/test-self-hosted.sh` (added tests for resumable upload, modified tests for Realtime and GraphQL) - PR [#46731](https://github.com/supabase/supabase/pull/46731), PR [#46856](https://github.com/supabase/supabase/pull/46856), PR [#46981](https://github.com/supabase/supabase/pull/46981)
|
||||
- Updated `tests/test-auth-keys.sh` (modified tests for Realtime) - PR [#46856](https://github.com/supabase/supabase/pull/46856)
|
||||
|
||||
### API gateway
|
||||
- ⚠️ Updated Kong and Envoy configuration to block access to Realtime `/api/tenants` and `/api/openapi` endpoints. This is a **security fix** (requires `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#46856](https://github.com/supabase/supabase/pull/46856)
|
||||
- Updated entrypoint for Kong to use `/bin/sh` (requires `docker-compose.yml` update) - PR [#46873](https://github.com/supabase/supabase/pull/46873)
|
||||
|
||||
### Studio
|
||||
- ⚠️ Updated `studio` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update). See discussion [#46081](https://github.com/orgs/supabase/discussions/46081) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) for important information - PR [#47022](https://github.com/supabase/supabase/pull/47022)
|
||||
|
||||
### PostgREST
|
||||
- Added healthcheck for `rest` (requires `docker-compose.yml` update) - PR [#46658](https://github.com/supabase/supabase/pull/46658)
|
||||
|
||||
### Postgres Meta
|
||||
- ⚠️ Updated `meta` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update) - PR [#47022](https://github.com/supabase/supabase/pull/47022)
|
||||
|
||||
### Edge Runtime
|
||||
- Added healthcheck for `functions` (requires `docker-compose.yml` update) - PR [#46655](https://github.com/supabase/supabase/pull/46655)
|
||||
|
||||
### Postgres
|
||||
- ⚠️ Updated the default image to `17.6.1.136` (from `15.8.1.085`). `pg_graphql` is now **disabled by default** on fresh installs. Databases that already use GraphQL keep it after an upgrade. See discussion [#46080](https://github.com/orgs/supabase/discussions/46080) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) for more information - PR [#46981](https://github.com/supabase/supabase/pull/46981)
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.5.0) - 2026-06-03
|
||||
|
||||
⚠️ **Note:** This update includes **important changes**. Please check the details below.
|
||||
|
||||
### Configuration
|
||||
- ⚠️ Logs and analytics are now [optional](https://github.com/orgs/supabase/discussions/46084) and were removed from the default `docker-compose.yml`. A new `docker-compose.logs.yml` override has been added. Check the main [configuration guide](https://supabase.com/docs/guides/self-hosting/docker#enabling-analytics) and the changes to Studio below for more information - PR [#45327](https://github.com/supabase/supabase/pull/45327) (via [@luizfelmach](https://github.com/luizfelmach/))
|
||||
- ⚠️ Added `COMPOSE_FILE` to `.env.example` for configuring compose overrides (also used by `run.sh`) - PR [#45603](https://github.com/supabase/supabase/pull/45603)
|
||||
|
||||
### Documentation
|
||||
- Added a new [reference list](https://github.com/supabase/supabase/blob/master/docker/CONFIG.md) of all configuration environment variables - PR [#46124](https://github.com/supabase/supabase/pull/46124)
|
||||
- Updated the main installation and configuration [guide](https://supabase.com/docs/guides/self-hosting/docker) (added "quick start" path and opt-in for logs and analytics; removed the legacy JWT secrets generator) - PR [#46416](https://github.com/supabase/supabase/pull/46416), PR [#45359](https://github.com/supabase/supabase/pull/45359)
|
||||
- Updated the logs and analytics [how-to guide](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#46452](https://github.com/supabase/supabase/pull/46452)
|
||||
|
||||
### Utils
|
||||
- Added `setup.sh` and `run.sh` to support quick start and easier management of the compose configuration - PR [#45603](https://github.com/supabase/supabase/pull/45603)
|
||||
- Updated `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-key.sh` to remove the dependency on OpenSSL and Node.js - PR [#45941](https://github.com/supabase/supabase/pull/45941)
|
||||
- Updated `tests/test-container-logs.sh` to skip checks for `kong`, `analytics` and `vector` when the services are not running - PR [#46099](https://github.com/supabase/supabase/pull/46099)
|
||||
|
||||
### API gateway
|
||||
- Updated Envoy version to `1.38.0` (see `docker-compose.envoy.yml`) - PR [#46023](https://github.com/supabase/supabase/pull/46023)
|
||||
- Updated Envoy configuration to address a discrepancy in API key checking (requires `volumes/api/envoy` update) - PR [#46023](https://github.com/supabase/supabase/pull/46023)
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.06.03-sha-0bca601`
|
||||
- ⚠️ Added `ENABLED_FEATURES_LOGS_ALL` to Studio service configuration (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
|
||||
- ⚠️ Added `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` to Studio service configuration (requires `docker-compose.yml` update) - PR [#46173](https://github.com/supabase/supabase/pull/46173)
|
||||
- ⚠️ Added `start_period` to Studio healthcheck for more reliable cold-boot on slower hosts (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
|
||||
- Fixed incorrect connection strings in the connect sheet for self-hosted environments - PR [#46217](https://github.com/supabase/supabase/pull/46217)
|
||||
- Updated project home and functions page, and added a minimal project settings implementation - PR [#46544](https://github.com/supabase/supabase/pull/46544), PR [#46550](https://github.com/supabase/supabase/pull/46550), PR [#46554](https://github.com/supabase/supabase/pull/46554)
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.189.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.189.0)
|
||||
- ⚠️ Added `GOTRUE_JWT_ISSUER` to Auth service configuration (requires `docker-compose.yml` update) - PR [#46020](https://github.com/supabase/supabase/pull/46020)
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v14.12` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.12)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.102.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.102.3)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.60.4` - [Release](https://github.com/supabase/storage/releases/tag/v1.60.4)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.96.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.6)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.74.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.74.0)
|
||||
|
||||
### Supavisor
|
||||
- Updated to `2.9.5` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.9.5)
|
||||
- Added `POSTGRES_HOST` to Supavisor service configuration (requires `docker-compose.yml` and `volumes/pooler/pooler.exs` update) - PR [#41273](https://github.com/supabase/supabase/pull/41273)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.43.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.43.1)
|
||||
- ⚠️ Changed default `docker-compose.yml` to no longer include logs & analytics. Read more in Supabase's [changelog](https://github.com/orgs/supabase/discussions/46084) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-27
|
||||
|
||||
### Configuration
|
||||
- ⚠️ Added `docker-compose.envoy.yml` and `volumes/api/envoy`. See also the API gateway updates below - PR [#43838](https://github.com/supabase/supabase/pull/43838)
|
||||
- ⚠️ Changed Studio healthcheck and some other configuration for better compatibility with Podman (requires `docker-compose.yml` update) - PR [#44754](https://github.com/supabase/supabase/pull/44754)
|
||||
- ⚠️ Changed Studio configuration to bind to all IPv4 interfaces only (requires `docker-compose.yml` update) - PR [#44772](https://github.com/supabase/supabase/pull/44772)
|
||||
|
||||
### Documentation
|
||||
- Added a new [how-to](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) describing how to switch from `supabase_admin` to `postgres` role for Studio - PR [#42975](https://github.com/supabase/supabase/pull/42975) (via [@singh-inder](https://github.com/singh-inder/))
|
||||
- Added a new [how-to](https://github.com/supabase/supabase/pull/45152) for configuring Envoy as the new API gateway - PR [#45152](https://github.com/supabase/supabase/pull/45152)
|
||||
- Updated the main [setup guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos to reflect the state of the self-hosted Supabase configuration - PR [#45011](https://github.com/supabase/supabase/pull/45011)
|
||||
|
||||
### Utils
|
||||
- ⚠️ Added `utils/reassign-owner.sh` to update database objects. Read more in the "[Remove superuser access](https://supabase.com/docs/guides/self-hosting/remove-superuser-access)" how-to guide - PR [#42975](https://github.com/supabase/supabase/pull/42975)
|
||||
- ⚠️ Changed `utils/add-new-auth-keys.sh` to also update `docker-compose.yml` - PR [#45056](https://github.com/supabase/supabase/pull/45056)
|
||||
|
||||
### API gateway
|
||||
- ⚠️ Added Envoy as the new optional API gateway (requires `docker-compose.envoy.yml`, `volumes/api/envoy`, and `volumes/logs/vector.yml` update) - PR [#43838](https://github.com/supabase/supabase/pull/43838) (via [@luizfelmach](https://github.com/luizfelmach/))
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.04.27-sha-5f60601`
|
||||
- ⚠️ Added 4 new lints to the Security Advisor. Read more about lint rules 0026 - 0029 in the [Performance and Security Advisors](https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0026_pg_graphql_anon_table_exposed) section of the Supabase documentation - PR [#45253](https://github.com/supabase/supabase/pull/45253), PR [#45260](https://github.com/supabase/supabase/pull/45260)
|
||||
---
|
||||
|
||||
## 2026-04-08
|
||||
|
||||
### Documentation
|
||||
- Added new how-to guides for configuring [custom email templates](https://supabase.com/docs/guides/self-hosting/custom-email-templates), setting up [SAML SSO](https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso), and [using Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) - PR [#42832](https://github.com/supabase/supabase/pull/42832), PR [#43386](https://github.com/supabase/supabase/pull/43386), PR [#44147](https://github.com/supabase/supabase/pull/44147)
|
||||
|
||||
### Utils
|
||||
- ⚠️ Added `utils/upgrade-pg17.sh`. Read more in the "[Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17)" how-to guide - PR [#44147](https://github.com/supabase/supabase/pull/44147)
|
||||
|
||||
### API gateway
|
||||
- ⚠️ Added configuration for SAML SSO (requires `.env`, `docker-compose.yml` and `volumes/api/kong.yml` update) - PR [#43385](https://github.com/supabase/supabase/pull/43385) (via [@luizfelmach](https://github.com/luizfelmach/))
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.04.08-sha-205cbe7`
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v14.8` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.8)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.48.26` - [Release](https://github.com/supabase/storage/releases/tag/v1.48.26)
|
||||
|
||||
### imgproxy
|
||||
- Changed `IMGPROXY_ENABLE_WEBP_DETECTION` environment variable to `IMGPROXY_AUTO_WEBP` (requires `.env` and `docker-compose.yml` update) - PR [#43919](https://github.com/supabase/supabase/pull/43919)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.96.3` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.3)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.36.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.36.1)
|
||||
|
||||
### Postgres
|
||||
- ⚠️ Added `docker-compose.pg17.yml` override - PR [#44147](https://github.com/supabase/supabase/pull/44147)
|
||||
- ⚠️ Added `utils/upgrade-pg17.sh` - PR [#44147](https://github.com/supabase/supabase/pull/44147)
|
||||
- ⚠️ Added [documentation](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) explaining the upgrade to Postgres 17
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-16
|
||||
|
||||
⚠️ **Note:** This update includes **important changes**. Please check the details below. The following configuration files have been added/updated: `utils/add-new-auth-keys.sh`, `utils/rotate-new-api-keys.sh`, `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `docker-compose.rustfs.yml`, `volumes/api/kong.yml`, `volumes/api/kong-entrypoint.sh`, `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/functions/main/index.ts`, and `volumes/proxy`.
|
||||
|
||||
### Configuration
|
||||
- ⚠️ Added scripts and templates to support the new API key format (`sb_` API keys) and the new asymmetric authentication. Check the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) for detailed instructions - PR [#43554](https://github.com/supabase/supabase/pull/43554)
|
||||
- Added optional proxy configuration for Caddy and nginx. Read the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https) to learn more - PR [#43291](https://github.com/supabase/supabase/pull/43291)
|
||||
|
||||
### Documentation
|
||||
- Added several new how-to guides to the self-hosted Supabase [documentation](https://supabase.com/docs/guides/self-hosting) - PR [#42745](https://github.com/supabase/supabase/pull/42745), PR [#42953](https://github.com/supabase/supabase/pull/42953), PR [#43177](https://github.com/supabase/supabase/pull/43177), PR [#43286](https://github.com/supabase/supabase/pull/43286), PR [#43293](https://github.com/supabase/supabase/pull/43293)
|
||||
|
||||
### Utils and tests
|
||||
- Added `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-keys.sh` - PR [#43554](https://github.com/supabase/supabase/pull/43554)
|
||||
- Added `tests/` with 100+ test cases - PR [#43573](https://github.com/supabase/supabase/pull/43573)
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.03.16-sha-5528817`
|
||||
- ⚠️ Added the link to the Data API page in Integrations - PR [#43268](https://github.com/supabase/supabase/pull/43268)
|
||||
- ⚠️ Added `PGRST_DB_SCHEMAS`, `PGRST_DB_EXTRA_SEARCH_PATH`, and `PGRST_DB_MAX_ROWS` to Studio configuration (requires `docker-compose.yml` update) - PR [#43268](https://github.com/supabase/supabase/pull/43268)
|
||||
|
||||
### MCP Server
|
||||
- Updated to `v0.7.0` - [Release](https://github.com/supabase/mcp/releases/tag/v0.7.0)
|
||||
|
||||
### API gateway
|
||||
- ⚠️ Updated Kong to `3.9.1` - PR [#43554](https://github.com/supabase/supabase/pull/43554)
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v14.6` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.6)
|
||||
|
||||
### Realtime
|
||||
- ⚠️ Added **mandatory** `METRICS_JWT_SECRET` environment variable (requires `docker-compose.s3.yml` update) - PR [realtime#1729](https://github.com/supabase/realtime/pull/1729)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.44.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.44.2)
|
||||
- ⚠️ Added `STORAGE_PUBLIC_URL` environment variable to simplify proxy configuration (requires `docker-compose.s3.yml` update) - PR [storage#900](https://github.com/supabase/storage/pull/900)
|
||||
- ⚠️ Added RustFS as an optional S3 backend - PR [#42935](https://github.com/supabase/supabase/pull/42935)
|
||||
- ⚠️ Changed Docker Compose configuration for S3 backends to use named volumes - PR [#43815](https://github.com/supabase/supabase/pull/43815)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.71.2` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.71.2)
|
||||
- ⚠️ Added `SUPABASE_PUBLISHABLE_KEYS`, `SUPABASE_SECRET_KEYS`, and `SUPABASE_PUBLIC_URL` environment variables (requires `docker-compose.yml` update)
|
||||
- ⚠️ Added an option for a "hybrid" JWT verification following the addition of the new API keys and the new asymmetric authentication (requires `volumes/functions/main/index.ts` update) - PR [#42130](https://github.com/supabase/supabase/pull/42130)
|
||||
- ⚠️ Added optional rate limiter - PR [edge-runtime#670](https://github.com/supabase/edge-runtime/pull/670)
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-18
|
||||
|
||||
### Storage
|
||||
- Changed MinIO image to use Chainguard [minio](https://images.chainguard.dev/directory/image/minio/overview) and [minio-client](https://images.chainguard.dev/directory/image/minio-client/overview) (requires `docker-compose.s3.yml` update) - PR [#42942](https://github.com/supabase/supabase/pull/42942)
|
||||
- Updated Storage image version to `v1.37.8` in `docker-compose.s3.yml`
|
||||
- Removed `imgproxy` service from `docker-compose.s3.yml` to minimize redundancy - PR [#42942](https://github.com/supabase/supabase/pull/42942)
|
||||
- Fixed inconsistent `storage` service entry ordering in `docker-compose.yml` and `docker-compose.s3.yml` to improve diff readability - PR [#42942](https://github.com/supabase/supabase/pull/42942)
|
||||
|
||||
### Edge Runtime
|
||||
- Added a `deno-cache` named volume to avoid re-downloading dependencies (requires `docker-compose.yml` and `volumes/functions/*` update) - PR [#40822](https://github.com/supabase/supabase/pull/40822)
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-16
|
||||
|
||||
⚠️ **Note:** This update includes several breaking changes, including a security fix for Analytics. Please check the details below. The following configuration files have been updated: `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `volumes/api/kong.yml`, and `volumes/logs/vector.yml`.
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.02.16-sha-26c615c`
|
||||
- Added Edge Functions management UI (requires `docker-compose.yml` update) - PR [#40690](https://github.com/supabase/supabase/pull/40690), PR [#42322](https://github.com/supabase/supabase/pull/42322), PR [#42349](https://github.com/supabase/supabase/pull/42349), PR [#42350](https://github.com/supabase/supabase/pull/42350)
|
||||
|
||||
### MCP Server
|
||||
- Updated to `v0.6.3` - [Release](https://github.com/supabase/mcp/releases/tag/v0.6.3)
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.186.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.186.0)
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v14.5` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.5)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.76.5` - [Release](https://github.com/supabase/realtime/releases/tag/v2.76.5)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.37.8` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.8)
|
||||
- ⚠️ Changed environment variable configuration for Storage (requires `docker-compose.yml`, `.env.example` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185), PR [#42862](https://github.com/supabase/supabase/pull/42862)
|
||||
- ⚠️ Added **default** configuration to access buckets via `/storage/v1/s3` endpoint (requires `docker-compose.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185)
|
||||
- ⚠️ Changed MinIO configuration for the S3 backend (requires `docker-compose.s3.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.70.3` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.3)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.31.2` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.31.2)
|
||||
- ⚠️ Changed default configuration to disable Logflare on `0.0.0.0:4000` to prevent access to `/dashboard` (requires `docker-compose.yml` update). Read more in the "Production Recommendations" section of Logflare [documentation](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
|
||||
- ⚠️ Changed Kong routes to not include `/analytics/v1` by default (requires `/volumes/api/kong.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
|
||||
|
||||
### Vector
|
||||
- Updated to `0.53.0-alpine` - [Changelog](https://vector.dev/releases/0.53.0/) | [Release](https://github.com/vectordotdev/vector/releases/tag/v0.53.0)
|
||||
- ⚠️ Major version jump from `0.28.1` (requires `volumes/logs/vector.yml` update) - PR [#42525](https://github.com/supabase/supabase/pull/42525)
|
||||
- ⚠️ Changed Postgres sink configuration to bypass Kong (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
|
||||
- ⚠️ Changed retry settings for all sinks to increase timeouts (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-05
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.37.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.1)
|
||||
- Fixed an issue with Storage not starting because of an issue with migrations - PR [storage#845](https://github.com/supabase/storage/pull/845)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-27
|
||||
|
||||
### Studio
|
||||
- Updated to `2026.01.27-sha-6aa59ff`
|
||||
- Added SQL snippets (requires `docker-compose.yml` update) - PR [#41112](https://github.com/supabase/supabase/pull/41112), PR [#41557](https://github.com/supabase/supabase/pull/41557), discussion [#42031](https://github.com/orgs/supabase/discussions/42031)
|
||||
- Fixed type generator - PR [#40481](https://github.com/supabase/supabase/pull/40481)
|
||||
- Fixed minor UI discrepancies - PR [#40579](https://github.com/supabase/supabase/pull/40579), PR [#41936](https://github.com/supabase/supabase/pull/41936), PR [#41970](https://github.com/supabase/supabase/pull/41970), PR [#41971](https://github.com/supabase/supabase/pull/41971), PR [#41972](https://github.com/supabase/supabase/pull/41972), PR [#42015](https://github.com/supabase/supabase/pull/42015)
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.185.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.185.0)
|
||||
- ⚠️ Fixed security-related issues
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v14.3` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.3)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.72.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.72.0)
|
||||
- Changed healthchecks logging to off by default (requires `docker-compose.yml` update) - PR [realtime#1677](https://github.com/supabase/realtime/pull/1677), PR [#42156](https://github.com/supabase/supabase/pull/42156)
|
||||
- Changed logging configuration and healthcheck frequency to reduce log volume (requires `docker-compose.yml` update) - PR [#42112](https://github.com/supabase/supabase/pull/42112)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.33.5` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.5)
|
||||
|
||||
### imgproxy
|
||||
- Updated to `v3.30.1` - [Changelog](https://github.com/imgproxy/imgproxy/blob/master/CHANGELOG.md) | [Release](https://github.com/imgproxy/imgproxy/releases/tag/v3.30.1)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.95.2` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.2)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.70.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.0)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.30.3` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.30.3)
|
||||
|
||||
### Postgres
|
||||
- No image update
|
||||
- Fixed Postgres logging configuration (requires `volumes/logs/vector.yml` update) - PR [#41800](https://github.com/supabase/supabase/pull/41800)
|
||||
|
||||
---
|
||||
|
||||
## 2025-12-18
|
||||
|
||||
### Documentation
|
||||
- Updated self-hosting installation and configuration guide - PR [#40901](https://github.com/supabase/supabase/pull/40901), PR [#41438](https://github.com/supabase/supabase/pull/41438)
|
||||
|
||||
### Utils
|
||||
- Added `utils/generate-keys.sh` - PR [#41363](https://github.com/supabase/supabase/pull/41363)
|
||||
- Added `utils/db-passwd.sh` - PR [#41432](https://github.com/supabase/supabase/pull/41432)
|
||||
- Changed `reset.sh` to POSIX and added more checks - PR [#41361](https://github.com/supabase/supabase/pull/41361)
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.12.17-sha-43f4f7f`
|
||||
- ⚠️ Fixed additional issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell)
|
||||
- Fixed an issue with the Users page not being updated on changes - PR [#41254](https://github.com/supabase/supabase/pull/41254)
|
||||
|
||||
### MCP Server
|
||||
- Updated to `v0.5.10` - [Release](https://github.com/supabase/mcp/releases/tag/v0.5.10)
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.184.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.184.0)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.95.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.1)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.27.0` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.27.0)
|
||||
- Fixed multiple issues, including a race condition
|
||||
|
||||
---
|
||||
|
||||
## 2025-12-10
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.12.09-sha-434634f`
|
||||
- ⚠️ Fixed security issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell)
|
||||
|
||||
### MCP Server
|
||||
- Updated to `v0.5.9` - [Release](https://github.com/supabase/mcp/releases/tag/v0.5.9)
|
||||
- ⚠️ Changed MCP tool `get_anon_key` to `get_publishable_keys`
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v14.1` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.1)
|
||||
- ⚠️ **Major upgrade from v13.x to v14.x** - please report any unexpected behavior
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.68.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.68.0)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.33.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.0)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.69.28` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.28)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.26.25` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.25)
|
||||
|
||||
---
|
||||
|
||||
## 2025-12-08
|
||||
|
||||
### Realtime
|
||||
- No image update
|
||||
- Changed boolean values to strings in Docker Compose for better compatibility with Podman - PR [#40994](https://github.com/supabase/supabase/pull/40994), also PR [realtime#1614](https://github.com/supabase/realtime/pull/1614)
|
||||
- Changed healthcheck in Docker Compose for better compatibility with Podman - PR [#41159](https://github.com/supabase/supabase/pull/41159)
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-26
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.11.26-sha-8f096b5`
|
||||
- Fixed MCP `get_advisors` tool - PR [#40783](https://github.com/supabase/supabase/pull/40783)
|
||||
- Fixed AI Assistant request schema - PR [#40830](https://github.com/supabase/supabase/pull/40830)
|
||||
- Fixed log drains page - PR [#40835](https://github.com/supabase/supabase/pull/40835)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.65.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.3)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.26.13` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.13)
|
||||
- Fixed crashdump when `POSTGRES_BACKEND_URL` is malformed - PR [logflare#2954](https://github.com/Logflare/logflare/pull/2954)
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-25
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.11.24-sha-d990ae8` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40734)
|
||||
- Fixed Queues configuration UI and added [documentation for exposed queue schema](https://supabase.com/docs/guides/queues/expose-self-hosted-queues) - PR [#40078](https://github.com/supabase/supabase/pull/40078)
|
||||
- Fixed parameterized SQL queries in MCP tools - PR [#40499](https://github.com/supabase/supabase/pull/40499)
|
||||
- Fixed Studio showing paid options for log drains - PR [#40510](https://github.com/supabase/supabase/pull/40510)
|
||||
- Fixed AI Assistant authentication - PR [#40654](https://github.com/supabase/supabase/pull/40654)
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.183.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.183.0)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.65.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.2)
|
||||
- Fixed handling of boolean configuration options - PR [realtime#1614](https://github.com/supabase/realtime/pull/1614)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.32.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.32.0)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.69.25` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.25)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.26.12` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.12)
|
||||
- Fixed Auth logs query - PR [logflare#2936](https://github.com/Logflare/logflare/pull/2936)
|
||||
- Fixed build configuration to prevent crashes with "Illegal instruction (core dumped)" - PR [logflare#2942](https://github.com/Logflare/logflare/pull/2942)
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-17
|
||||
|
||||
### Storage
|
||||
- No image update
|
||||
- Fixed resumable uploads for files larger than 6MB (requires `docker-compose.yml` update) - PR [#40500](https://github.com/supabase/supabase/pull/40500)
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-12
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
|
||||
- Added log drains - PR [#28297](https://github.com/supabase/supabase/pull/28297)
|
||||
- Fixed Studio using `postgres` role instead of `supabase_admin` - PR [#39946](https://github.com/supabase/supabase/pull/39946)
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.63.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.63.0)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.29.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.29.0)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.69.23` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.23)
|
||||
|
||||
### Supavisor
|
||||
- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4)
|
||||
|
||||
---
|
||||
|
||||
## 2025-11-05
|
||||
|
||||
### Studio
|
||||
- No image update
|
||||
- Fixed Studio failing to connect to Postgres with non-default settings (requires `docker-compose.yml` update) - PR [#40169](https://github.com/supabase/supabase/pull/40169)
|
||||
|
||||
### Realtime
|
||||
- No image update
|
||||
- Fixed realtime logs not showing in Studio (requires `volumes/logs/vector.yml` update) - PR [#39963](https://github.com/supabase/supabase/pull/39963)
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-28
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.10.27-sha-85b84e0` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
|
||||
- Fixed broken authentication when uploading files to Storage - PR [#39829](https://github.com/supabase/supabase/pull/39829)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.57.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.57.2)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.28.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.2)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.93.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.1)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.69.15` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.15)
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-27
|
||||
|
||||
### Studio
|
||||
- No image update
|
||||
- Added Kong configuration for MCP server routes (requires `volumes/api/kong.yml` update) - PR [#39849](https://github.com/supabase/supabase/pull/39849)
|
||||
- Added [documentation page](https://supabase.com/docs/guides/self-hosting/enable-mcp) for MCP server configuration - PR [#39952](https://github.com/supabase/supabase/pull/39952)
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-21
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.10.20-sha-5005fc6` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
|
||||
- Fixed issues with Edge Functions and cron logs not being visible in Studio - PR [#39388](https://github.com/supabase/supabase/pull/39388), PR [#39704](https://github.com/supabase/supabase/pull/39704), PR [#39711](https://github.com/supabase/supabase/pull/39711)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.56.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.56.0)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.28.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.1)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.93.0` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.0)
|
||||
|
||||
### Edge Runtime
|
||||
- Updated to `v1.69.14` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.14)
|
||||
|
||||
### Supavisor
|
||||
- Updated to `2.7.3` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.3)
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-13
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.22.6` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.6)
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-08
|
||||
|
||||
### Studio
|
||||
- Updated to `2025.10.01-sha-8460121` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
|
||||
- Added "local" remote MCP server - PR [#38797](https://github.com/supabase/supabase/pull/38797), PR [#39041](https://github.com/supabase/supabase/pull/39041)
|
||||
- ⚠️ Changed Studio connection method to `postgres-meta` - affects non-standard database port configurations
|
||||
|
||||
### Auth
|
||||
- Updated to `v2.180.0` - [Release](https://github.com/supabase/auth/releases/tag/v2.180.0)
|
||||
|
||||
### PostgREST
|
||||
- Updated to `v13.0.7` - [Release](https://github.com/PostgREST/postgrest/releases/tag/v13.0.7) | [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md)
|
||||
|
||||
### Realtime
|
||||
- Updated to `v2.51.11` - [Release](https://github.com/supabase/realtime/releases/tag/v2.51.11)
|
||||
|
||||
### Storage
|
||||
- Updated to `v1.28.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.0)
|
||||
|
||||
### Postgres Meta
|
||||
- Updated to `v0.91.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.91.6)
|
||||
|
||||
### Analytics (Logflare)
|
||||
- Updated to `1.22.4` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.4)
|
||||
|
||||
### Postgres
|
||||
- Updated to `15.8.1.085` - [Release](https://github.com/supabase/postgres/releases/tag/15.8.1.085)
|
||||
|
||||
### Supavisor
|
||||
- Updated to `2.7.0` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.0)
|
||||
|
||||
---
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
# Reverse proxy di produzione, TLS automatico via Let's Encrypt.
|
||||
# Copia in Caddyfile, sostituisci il dominio, poi:
|
||||
# caddy run --config Caddyfile
|
||||
# (o come container: docker run -p 80:80 -p 443:443 -v ./Caddyfile:/etc/caddy/Caddyfile caddy)
|
||||
#
|
||||
# Un solo hostname: /api/* va al gateway Supabase (envoy accetta qualsiasi Host, instrada per
|
||||
# path), tutto il resto va all'app. handle_path toglie il prefisso /api prima di inoltrare, quindi
|
||||
# VITE_SUPABASE_URL nell'app deve essere https://<dominio>/api (supabase-js aggiunge da solo
|
||||
# /rest/v1, /auth/v1, ecc.).
|
||||
|
||||
crapp.ddns.net {
|
||||
handle_path /api/* {
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)
|
||||
|
||||
</div>
|
||||
|
||||
# Self-Hosted Supabase with Docker
|
||||
|
||||
This is the official Docker Compose setup for self-hosted Supabase. It provides a complete stack with all Supabase services running locally or on your infrastructure.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Follow the detailed setup guide in our documentation: [Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)
|
||||
|
||||
The guide covers:
|
||||
- Prerequisites (Git and Docker)
|
||||
- Initial setup and configuration
|
||||
- Securing your installation
|
||||
- Accessing services
|
||||
- Updating your instance
|
||||
|
||||
## What's Included
|
||||
|
||||
This Docker Compose configuration includes the following services:
|
||||
|
||||
- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project
|
||||
- **[Envoy](https://www.envoyproxy.io/)** - API gateway (default; Kong is available as an optional override via `sh run.sh config add kong`)
|
||||
- **[Auth](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management
|
||||
- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your PostgreSQL database directly into a RESTful API
|
||||
- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to PostgreSQL database changes and broadcasts them over websockets
|
||||
- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions
|
||||
- **[imgproxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server
|
||||
- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries)
|
||||
- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development
|
||||
- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services
|
||||
- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform
|
||||
- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs
|
||||
- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)** - Setup and configuration guides
|
||||
- **[CHANGELOG.md](./CHANGELOG.md)** - Track recent updates and changes to services
|
||||
- **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference
|
||||
- **[Ask DeepWiki / Supabase](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)** - DeepWiki-generated description of self-hosted configuration
|
||||
- **[CONFIG.md](./CONFIG.md)** - Configuration reference for all environment variables
|
||||
- **[Update your deployment](https://supabase.com/docs/guides/self-hosting/updating)** - Update an existing deployment with `update.sh`
|
||||
|
||||
## Updates
|
||||
|
||||
Back up your database, then:
|
||||
|
||||
```sh
|
||||
sh update.sh --dry-run # optional preview
|
||||
sh update.sh
|
||||
sh run.sh pull && sh run.sh recreate
|
||||
```
|
||||
|
||||
See the **[update guide](https://supabase.com/docs/guides/self-hosting/updating)** for conflicts,
|
||||
breaking changes, pinning a release, and older installs without `.supabase-version`.
|
||||
|
||||
## Community & Support
|
||||
|
||||
For troubleshooting common issues, see:
|
||||
- [GitHub Discussions](https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted) - Questions, feature requests, and workarounds
|
||||
- [GitHub Issues](https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted) - Known issues
|
||||
- [Documentation](https://supabase.com/docs/guides/self-hosting) - Setup and configuration guides
|
||||
|
||||
Self-hosted Supabase is community-supported. Get help and connect with other users:
|
||||
|
||||
- [Discord](https://discord.supabase.com) - Real-time chat and community support
|
||||
- [Reddit](https://www.reddit.com/r/Supabase/) - Official Supabase subreddit
|
||||
|
||||
Share your self-hosting experience:
|
||||
|
||||
- [GitHub Discussions](https://github.com/orgs/supabase/discussions/39820) - "Self-hosting: What's working (and what's not)?"
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Security
|
||||
|
||||
⚠️ **The default configuration is not secure for production use.**
|
||||
|
||||
Before deploying to production, you must:
|
||||
- [Update](https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase) all default passwords and secrets in the `.env` file
|
||||
- Review and update CORS settings
|
||||
- Consider setting up a secure proxy in front of self-hosted Supabase
|
||||
- Review and adjust network security configuration (ACLs, etc.)
|
||||
- Set up proper backup procedures
|
||||
|
||||
See the [main installation guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos in the documentation.
|
||||
|
||||
## License
|
||||
|
||||
This repository is licensed under the Apache 2.0 License. See the main [Supabase repository](https://github.com/supabase/supabase) for details.
|
||||
@@ -1,48 +0,0 @@
|
||||
create table profiles (
|
||||
id uuid references auth.users not null,
|
||||
updated_at timestamp with time zone,
|
||||
username text unique,
|
||||
avatar_url text,
|
||||
website text,
|
||||
|
||||
primary key (id),
|
||||
unique(username),
|
||||
constraint username_length check (char_length(username) >= 3)
|
||||
);
|
||||
|
||||
alter table profiles enable row level security;
|
||||
|
||||
create policy "Public profiles are viewable by the owner."
|
||||
on profiles for select
|
||||
using ( auth.uid() = id );
|
||||
|
||||
create policy "Users can insert their own profile."
|
||||
on profiles for insert
|
||||
with check ( auth.uid() = id );
|
||||
|
||||
create policy "Users can update own profile."
|
||||
on profiles for update
|
||||
using ( auth.uid() = id );
|
||||
|
||||
-- Set up Realtime
|
||||
begin;
|
||||
drop publication if exists supabase_realtime;
|
||||
create publication supabase_realtime;
|
||||
commit;
|
||||
alter publication supabase_realtime add table profiles;
|
||||
|
||||
-- Set up Storage
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
|
||||
create policy "Avatar images are publicly accessible."
|
||||
on storage.objects for select
|
||||
using ( bucket_id = 'avatars' );
|
||||
|
||||
create policy "Anyone can upload an avatar."
|
||||
on storage.objects for insert
|
||||
with check ( bucket_id = 'avatars' );
|
||||
|
||||
create policy "Anyone can update an avatar."
|
||||
on storage.objects for update
|
||||
with check ( bucket_id = 'avatars' );
|
||||
@@ -1,44 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
studio:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/studio/Dockerfile
|
||||
target: dev
|
||||
ports:
|
||||
- 8082:8082
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: ../apps/studio
|
||||
target: /app/apps/studio
|
||||
ignore:
|
||||
- node_modules/
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
|
||||
mail:
|
||||
container_name: supabase-mail
|
||||
image: inbucket/inbucket:3.0.3
|
||||
ports:
|
||||
- '2500:2500' # SMTP
|
||||
- '9000:9000' # web interface
|
||||
- '1100:1100' # POP3
|
||||
auth:
|
||||
environment:
|
||||
- GOTRUE_SMTP_USER=
|
||||
- GOTRUE_SMTP_PASS=
|
||||
meta:
|
||||
ports:
|
||||
- 5555:8080
|
||||
db:
|
||||
restart: 'no'
|
||||
volumes:
|
||||
# Always use a fresh database when developing
|
||||
- /var/lib/postgresql/data
|
||||
# Seed data should be inserted last (alphabetical order)
|
||||
- ./dev/data.sql:/docker-entrypoint-initdb.d/seed.sql
|
||||
storage:
|
||||
volumes:
|
||||
- /var/lib/storage
|
||||
@@ -1,42 +0,0 @@
|
||||
services:
|
||||
|
||||
# Caddy terminates TLS and forwards to the API gateway (api-gw) on port 8000,
|
||||
# so the gateway's own host port binding is removed here. This works for
|
||||
# either gateway, since the service is named api-gw in both cases.
|
||||
api-gw:
|
||||
ports: !reset []
|
||||
# When using the Kong override uncomment the following:
|
||||
#environment:
|
||||
# KONG_PORT_MAPS: "443:8000,443:8443"
|
||||
|
||||
caddy:
|
||||
container_name: supabase-caddy
|
||||
image: caddy:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
depends_on:
|
||||
api-gw:
|
||||
condition: service_healthy
|
||||
studio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PROXY_DOMAIN: ${PROXY_DOMAIN}
|
||||
PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME}
|
||||
PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
PROXY_AUTH_PASSWORD=$$(caddy hash-password --plaintext "$$PROXY_AUTH_PASSWORD") && \
|
||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
volumes:
|
||||
- ./volumes/proxy/caddy:/etc/caddy
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -1,15 +0,0 @@
|
||||
# DEPRECATED: Envoy is now the default API gateway defined directly in
|
||||
# docker-compose.yml, so this override is no longer needed and does nothing.
|
||||
#
|
||||
# This no-op shim is kept for one release cycle so existing COMPOSE_FILE
|
||||
# entries referencing it do not break. Remove it from your configuration:
|
||||
#
|
||||
# sh run.sh config remove envoy
|
||||
#
|
||||
# To run Kong instead of Envoy, use the Kong override:
|
||||
#
|
||||
# sh run.sh config add kong
|
||||
#
|
||||
# This file will be removed in a future release.
|
||||
|
||||
services: {}
|
||||
@@ -1,49 +0,0 @@
|
||||
# Kong API gateway override.
|
||||
#
|
||||
# Replaces the default Envoy gateway with Kong. Enable with:
|
||||
# sh run.sh config add kong
|
||||
# sh run.sh start
|
||||
#
|
||||
# This overrides the `api-gw` service in place, so its dependents (functions),
|
||||
# the reverse-proxy overrides (caddy/nginx), and the `kong`/`envoy` network
|
||||
# aliases keep working unchanged. Kong re-adds an HTTPS listener on 8443.
|
||||
|
||||
services:
|
||||
api-gw:
|
||||
container_name: supabase-kong
|
||||
image: kong/kong:3.9.3
|
||||
healthcheck:
|
||||
test: !override ["CMD", "kong", "health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports: !override
|
||||
- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp
|
||||
- ${KONG_HTTPS_PORT:-8443}:8443/tcp
|
||||
volumes: !override
|
||||
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
|
||||
- ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
|
||||
#- ./volumes/api/server.crt:/home/kong/server.crt:ro
|
||||
#- ./volumes/api/server.key:/home/kong/server.key:ro
|
||||
environment: !override
|
||||
KONG_DATABASE: "off"
|
||||
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
|
||||
KONG_ROUTER_FLAVOR: expressions
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_DNS_NOT_FOUND_TTL: 1
|
||||
KONG_DNS_VALID_TTL: 5
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
|
||||
#KONG_SSL_CERT: /home/kong/server.crt
|
||||
#KONG_SSL_CERT_KEY: /home/kong/server.key
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
|
||||
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
|
||||
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
|
||||
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
|
||||
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
|
||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
entrypoint: !override ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
|
||||
@@ -1,99 +0,0 @@
|
||||
# This override adds the following to the self-hosted Supabase configuration:
|
||||
# - Logflare: Log management and event analytics platform
|
||||
# - Vector: High-performance observability data pipeline for logs
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.logs.yml up -d
|
||||
|
||||
services:
|
||||
|
||||
studio:
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
||||
LOGFLARE_URL: http://analytics:4000
|
||||
ENABLED_FEATURES_LOGS_ALL: "true"
|
||||
|
||||
analytics:
|
||||
container_name: supabase-analytics
|
||||
image: supabase/logflare:1.43.1
|
||||
restart: unless-stopped
|
||||
#ports:
|
||||
# - 4000:4000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -sSfL -o /dev/null http://localhost:4000/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOGFLARE_NODE_HOST: 127.0.0.1
|
||||
|
||||
DB_USERNAME: supabase_admin
|
||||
DB_DATABASE: _supabase
|
||||
DB_HOSTNAME: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_SCHEMA: _analytics
|
||||
|
||||
# Enable single-tenant mode for Logflare
|
||||
LOGFLARE_SINGLE_TENANT: "true"
|
||||
# Seed Supabase-related metadata
|
||||
LOGFLARE_SUPABASE_MODE: "true"
|
||||
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
||||
|
||||
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
|
||||
|
||||
# Comment out the following two variables when switching to
|
||||
# the BigQuery backend for logs
|
||||
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||
POSTGRES_BACKEND_SCHEMA: _analytics
|
||||
|
||||
# Uncomment to use the BigQuery backend for logs
|
||||
#GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
|
||||
#GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
|
||||
# Uncomment to use the BigQuery backend for logs (requires gcloud.json
|
||||
# service account key from Google Cloud Console)
|
||||
#volumes:
|
||||
# - ./gcloud.json:/opt/app/rel/logflare/bin/gcloud.json:ro,z
|
||||
|
||||
vector:
|
||||
container_name: supabase-vector
|
||||
image: timberio/vector:0.53.0-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
||||
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://vector:9001/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
command:
|
||||
[
|
||||
"--config",
|
||||
"/etc/vector/vector.yml"
|
||||
]
|
||||
security_opt:
|
||||
- "label=disable"
|
||||
@@ -1,41 +0,0 @@
|
||||
services:
|
||||
|
||||
# nginx terminates TLS and forwards to the API gateway (api-gw) on port 8000,
|
||||
# so the gateway's own host port binding is removed here. This works for
|
||||
# either gateway, since the service is named api-gw in both cases.
|
||||
api-gw:
|
||||
ports: !reset []
|
||||
# When using the Kong override, uncomment the following:
|
||||
#environment:
|
||||
# KONG_PORT_MAPS: "443:8000,443:8443"
|
||||
|
||||
nginx:
|
||||
container_name: supabase-nginx
|
||||
image: jonasal/nginx-certbot:6.2.0-nginx1.31.3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
api-gw:
|
||||
condition: service_healthy
|
||||
studio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PROXY_DOMAIN: ${PROXY_DOMAIN}
|
||||
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
|
||||
PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME}
|
||||
PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
printf '%s:%s\n' "$${PROXY_AUTH_USERNAME}" "$$(openssl passwd -apr1 "$${PROXY_AUTH_PASSWORD}")" > /etc/nginx/user_conf.d/dashboard-passwd && \
|
||||
envsubst '$${PROXY_DOMAIN}' < /etc/nginx/supabase-nginx.conf.tpl > /etc/nginx/user_conf.d/nginx.conf && \
|
||||
/scripts/start_nginx_certbot.sh
|
||||
volumes:
|
||||
- ./volumes/proxy/nginx/supabase-nginx.conf.tpl:/etc/nginx/supabase-nginx.conf.tpl:ro
|
||||
- nginx_letsencrypt:/etc/letsencrypt
|
||||
|
||||
volumes:
|
||||
nginx_letsencrypt:
|
||||
@@ -1,18 +0,0 @@
|
||||
# Postgres 15 override for self-hosted Supabase.
|
||||
#
|
||||
# Postgres 17 is now the default in docker-compose.yml. Use this override to pin
|
||||
# Postgres 15, for two cases:
|
||||
# 1. An existing Postgres 15 deployment that has not been upgraded yet.
|
||||
# 2. As the rollback target for utils/upgrade-pg17.sh (PG 15 and PG 17 use
|
||||
# different postgres UIDs, so rollback must start with the PG 15 image).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
|
||||
#
|
||||
# To upgrade an existing Postgres 15 database to Postgres 17 in place, see:
|
||||
# - https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17
|
||||
# - utils/upgrade-pg17.sh
|
||||
|
||||
services:
|
||||
db:
|
||||
image: supabase/postgres:15.8.1.085
|
||||
@@ -1,19 +0,0 @@
|
||||
# Postgres 17 override for self-hosted Supabase.
|
||||
#
|
||||
# NOTE: Postgres 17 is now the default in docker-compose.yml, so this override
|
||||
# is redundant for new installs (kept for explicitness / backwards compatibility).
|
||||
# Keep the image tag here in sync with the db image in docker-compose.yml.
|
||||
#
|
||||
# Usage (new install or after upgrade):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
|
||||
#
|
||||
# For upgrading an existing Postgres 15 database, run utils/upgrade-pg17.sh first.
|
||||
#
|
||||
# Note: PG 15 and PG 17 use different postgres UIDs. If starting fresh with a
|
||||
# leftover db-config volume from PG 15, you may see
|
||||
# "FATAL: invalid secret key". Either remove the old volume or fix ownership.
|
||||
# See: docs/guides/self-hosting/postgres-upgrade-17
|
||||
|
||||
services:
|
||||
db:
|
||||
image: supabase/postgres:17.6.1.136
|
||||
@@ -1,56 +0,0 @@
|
||||
# PgBouncer override for self-hosted Supabase
|
||||
#
|
||||
# Replaces the default Supavisor pooler with PgBouncer in transaction mode.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up -d
|
||||
#
|
||||
# Or use run.sh to add PgBouncer to the stack:
|
||||
# sh run.sh config add pgbouncer
|
||||
# sh run.sh start
|
||||
#
|
||||
|
||||
services:
|
||||
supavisor: !reset null
|
||||
|
||||
# To expose Postgres directly on POSTGRES_PORT (5432), uncomment the "db"
|
||||
# section below.
|
||||
# WARNING: this opens Postgres to the external traffic. Restrict access at the
|
||||
# network level if necessary.
|
||||
#db:
|
||||
# ports:
|
||||
# - ${POSTGRES_PORT}:${POSTGRES_PORT}
|
||||
|
||||
pgbouncer:
|
||||
container_name: supabase-pgbouncer
|
||||
image: edoburu/pgbouncer:v1.25.2-p0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${POOLER_PROXY_PORT_TRANSACTION}:6432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-h', 'localhost', '-p', '6432']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# PgBouncer connects to Postgres as the dedicated `pgbouncer` role and
|
||||
# looks up every other role's password on demand via the auth_query below.
|
||||
DB_USER: pgbouncer
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_HOST: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
LISTEN_PORT: 6432
|
||||
AUTH_TYPE: scram-sha-256
|
||||
AUTH_QUERY: SELECT * FROM pgbouncer.get_auth($$1)
|
||||
# POOL_MODE can be: session, transaction, or statement
|
||||
POOL_MODE: transaction
|
||||
DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
|
||||
MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
|
||||
# Only the `pgbouncer` role may run get_auth / the admin console.
|
||||
ADMIN_USERS: pgbouncer
|
||||
STATS_USERS: pgbouncer
|
||||
@@ -1,34 +0,0 @@
|
||||
# Override di produzione, pensato per girare anche su hardware limitato (es. Raspberry Pi 4).
|
||||
#
|
||||
# 1. Nessuna porta esposta pubblicamente: l'API gateway (Kong/envoy, che serve anche Studio)
|
||||
# resta raggiungibile solo su 127.0.0.1 — mettici davanti un reverse proxy TLS (vedi
|
||||
# Caddyfile.example). Postgres di suo non espone porte nel compose base (supavisor sì,
|
||||
# ma non ci serve: l'app si connette al DB sulla rete interna Docker, non da host esterno).
|
||||
# 2. Servizi mai usati da CrAPP disattivati (nessuna route usa Storage/imgproxy/Realtime/Edge
|
||||
# Functions/pooler — verificato: zero `.storage.`, `.functions.invoke`, `.channel()` nel
|
||||
# codice, e gli avatar sono salvati come dataURL nel DB, non su Storage).
|
||||
# 3. `studio` + `meta` restano attivi: servono per guardare/gestire il database via interfaccia
|
||||
# grafica (raggiungibile tramite lo stesso api-gw su 127.0.0.1:8000, con le credenziali
|
||||
# DASHBOARD_USERNAME/DASHBOARD_PASSWORD del tuo .env).
|
||||
#
|
||||
# Uso: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
api-gw:
|
||||
# Compose unisce le liste `ports` tra file invece di sostituirle: serve !override
|
||||
# per rimpiazzare davvero il binding pubblico del file base con uno solo-locale.
|
||||
ports: !override
|
||||
- "127.0.0.1:${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp"
|
||||
|
||||
# Mai usati da CrAPP: profilo "unused" mai attivato, quindi `docker compose up -d`
|
||||
# di default non li avvia nemmeno.
|
||||
realtime:
|
||||
profiles: ["unused"]
|
||||
storage:
|
||||
profiles: ["unused"]
|
||||
imgproxy:
|
||||
profiles: ["unused"]
|
||||
functions:
|
||||
profiles: ["unused"]
|
||||
supavisor:
|
||||
profiles: ["unused"]
|
||||
@@ -1,47 +0,0 @@
|
||||
services:
|
||||
|
||||
rustfs:
|
||||
image: rustfs/rustfs:1.0.0-beta.11
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
|
||||
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
|
||||
RUSTFS_CORS_ALLOWED_ORIGINS: "*"
|
||||
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*"
|
||||
security_opt:
|
||||
- "no-new-privileges:true"
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://rustfs:9000/health" ]
|
||||
interval: 2s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
|
||||
rustfs-createbucket:
|
||||
image: rustfs/rc
|
||||
depends_on:
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -ec "
|
||||
rc alias set supa-rustfs http://rustfs:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
|
||||
rc mb --ignore-existing supa-rustfs/${GLOBAL_S3_BUCKET}
|
||||
"
|
||||
|
||||
storage:
|
||||
depends_on:
|
||||
rustfs-createbucket:
|
||||
condition: service_completed_successfully
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
STORAGE_BACKEND: s3
|
||||
GLOBAL_S3_ENDPOINT: http://rustfs:9000
|
||||
GLOBAL_S3_PROTOCOL: http
|
||||
GLOBAL_S3_FORCE_PATH_STYLE: true
|
||||
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
|
||||
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
|
||||
volumes:
|
||||
rustfs-data:
|
||||
@@ -1,46 +0,0 @@
|
||||
services:
|
||||
|
||||
minio:
|
||||
image: cgr.dev/chainguard/minio
|
||||
#ports:
|
||||
# - '9000:9000'
|
||||
# - '9001:9001'
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
command: server --console-address ":9001" /data
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
interval: 2s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
|
||||
minio-createbucket:
|
||||
image: cgr.dev/chainguard/minio-client:latest-dev
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -ec "
|
||||
mc alias set supa-minio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
|
||||
mc mb --ignore-existing supa-minio/${GLOBAL_S3_BUCKET}
|
||||
"
|
||||
|
||||
storage:
|
||||
depends_on:
|
||||
minio-createbucket:
|
||||
condition: service_completed_successfully
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
STORAGE_BACKEND: s3
|
||||
GLOBAL_S3_ENDPOINT: http://minio:9000
|
||||
GLOBAL_S3_PROTOCOL: http
|
||||
GLOBAL_S3_FORCE_PATH_STYLE: true
|
||||
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
|
||||
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
|
||||
volumes:
|
||||
minio-data:
|
||||
@@ -1,587 +0,0 @@
|
||||
# Usage
|
||||
# Start: docker compose up -d
|
||||
# Stop: docker compose down
|
||||
# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d
|
||||
# Reset everything: sh reset.sh
|
||||
#
|
||||
# Notes:
|
||||
# - Nested variable interpolation (${A:-${B}}) requires podman-compose >= 1.6.0
|
||||
#
|
||||
|
||||
name: supabase
|
||||
|
||||
services:
|
||||
|
||||
studio:
|
||||
container_name: supabase-studio
|
||||
image: supabase/studio:2026.08.03-sha-022b374
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
|
||||
]
|
||||
timeout: 10s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
environment:
|
||||
# Listen on all IPv4 interfaces
|
||||
HOSTNAME: "0.0.0.0"
|
||||
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
|
||||
# See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access
|
||||
POSTGRES_USER_READ_WRITE: postgres
|
||||
|
||||
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
|
||||
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
|
||||
|
||||
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
|
||||
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
|
||||
SUPABASE_URL: http://api-gw:8000
|
||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
|
||||
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
|
||||
|
||||
# See: docker-compose.logs.yml
|
||||
ENABLED_FEATURES_LOGS_ALL: "false"
|
||||
|
||||
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
|
||||
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
|
||||
volumes:
|
||||
- ./volumes/snippets:/app/snippets:z
|
||||
- ./volumes/functions:/app/edge-functions:ro,z
|
||||
|
||||
# Envoy is the default API gateway
|
||||
# See: https://github.com/orgs/supabase/discussions/48048
|
||||
api-gw:
|
||||
container_name: supabase-envoy
|
||||
image: envoyproxy/envoy:v1.39.0
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
default:
|
||||
# Expose `envoy` and `kong` as network aliases, so internal configs
|
||||
# that reference either hostname resolve to whichever gateway is active.
|
||||
aliases:
|
||||
- envoy
|
||||
- kong
|
||||
healthcheck:
|
||||
# Using a TCP port check because this image does not include curl or wget.
|
||||
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/8000'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
studio:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp
|
||||
volumes:
|
||||
- ./volumes/api/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro
|
||||
- ./volumes/api/envoy/cds.yaml:/etc/envoy/cds.yaml:ro
|
||||
- ./volumes/api/envoy/lds.template.yaml:/etc/envoy/lds.template.yaml:ro
|
||||
- ./volumes/api/envoy/docker-entrypoint.sh:/docker-entrypoint.sh:ro
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
|
||||
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
|
||||
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
|
||||
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
|
||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
|
||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
|
||||
|
||||
auth:
|
||||
container_name: supabase-auth
|
||||
image: supabase/gotrue:v2.189.0
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:9999/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
|
||||
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
|
||||
GOTRUE_SITE_URL: ${SITE_URL}
|
||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
|
||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
|
||||
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||
|
||||
# Legacy symmetric HS256 key
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
# JSON array of signing JWKs (EC private + legacy symmetric)
|
||||
# For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS}
|
||||
#GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]}
|
||||
|
||||
GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
|
||||
|
||||
# Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile.
|
||||
# GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: "true"
|
||||
|
||||
# GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true"
|
||||
# GOTRUE_SMTP_MAX_FREQUENCY: 1s
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
|
||||
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
||||
GOTRUE_SMTP_PORT: ${SMTP_PORT}
|
||||
GOTRUE_SMTP_USER: ${SMTP_USER}
|
||||
GOTRUE_SMTP_PASS: ${SMTP_PASS}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
|
||||
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
|
||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
|
||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
|
||||
|
||||
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
|
||||
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
|
||||
|
||||
# Uncomment to enable OAuth / social login providers.
|
||||
# GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED}
|
||||
# GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||
# GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET}
|
||||
# GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
|
||||
|
||||
# GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED}
|
||||
# GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
|
||||
# GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET}
|
||||
# GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
|
||||
|
||||
# GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED}
|
||||
# GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
|
||||
# GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET}
|
||||
# GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
|
||||
|
||||
# Uncomment to configure SMS delivery (phone auth and phone MFA).
|
||||
# GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER}
|
||||
# GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP}
|
||||
# GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH}
|
||||
# GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY}
|
||||
# GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE}
|
||||
|
||||
# Twilio credentials (when SMS_PROVIDER=twilio)
|
||||
# GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID}
|
||||
# GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN}
|
||||
# GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID}
|
||||
|
||||
# Test OTP mappings for development
|
||||
# GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP}
|
||||
|
||||
# Uncomment to configure multi-factor authentication (MFA).
|
||||
# GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED}
|
||||
# GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED}
|
||||
# GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED}
|
||||
# GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED}
|
||||
# GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS}
|
||||
|
||||
# SAML SSO
|
||||
# See: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso
|
||||
# GOTRUE_SAML_ENABLED: ${SAML_ENABLED}
|
||||
# GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY}
|
||||
# GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS}
|
||||
# GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD}
|
||||
# GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION}
|
||||
# Optional, defaults to API_EXTERNAL_URL if not set
|
||||
# GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL}
|
||||
|
||||
# Uncomment to enable custom access token hook.
|
||||
# See: https://supabase.com/docs/guides/auth/auth-hooks for
|
||||
# full list of hooks and additional details about custom_access_token_hook
|
||||
|
||||
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true"
|
||||
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook"
|
||||
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "<standard-base64-secret>"
|
||||
|
||||
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true"
|
||||
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt"
|
||||
|
||||
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true"
|
||||
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt"
|
||||
|
||||
# GOTRUE_HOOK_SEND_SMS_ENABLED: "false"
|
||||
# GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook"
|
||||
# GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
|
||||
|
||||
# GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false"
|
||||
# GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender"
|
||||
# GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
|
||||
|
||||
rest:
|
||||
container_name: supabase-rest
|
||||
image: postgrest/postgrest:v14.12
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: [ "CMD", "postgrest", "--ready" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
|
||||
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
|
||||
PGRST_ADMIN_SERVER_PORT: 3001
|
||||
PGRST_ADMIN_SERVER_HOST: localhost
|
||||
|
||||
# PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS.
|
||||
# For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or
|
||||
# PGRST_JWT_SECRET: ${JWT_JWKS}
|
||||
PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}
|
||||
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
|
||||
command:
|
||||
[
|
||||
"postgrest"
|
||||
]
|
||||
|
||||
realtime:
|
||||
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
|
||||
container_name: realtime-dev.supabase-realtime
|
||||
image: supabase/realtime:v2.102.3
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 30s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
environment:
|
||||
PORT: 4000
|
||||
DB_HOST: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_USER: supabase_admin
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||
DB_ENC_KEY: ${REALTIME_DB_ENC_KEY:-supabaserealtime}
|
||||
|
||||
# Legacy symmetric HS256 key
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
# JWKS for token verification (EC public + legacy symmetric).
|
||||
# For Podman, use: API_JWT_JWKS: ${JWT_JWKS}
|
||||
#API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
|
||||
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
METRICS_JWT_SECRET: ${JWT_SECRET}
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
DNS_NODES: "''"
|
||||
RLIMIT_NOFILE: "10000"
|
||||
APP_NAME: realtime
|
||||
SEED_SELF_HOST: "true"
|
||||
RUN_JANITOR: "true"
|
||||
DISABLE_HEALTHCHECK_LOGGING: "true"
|
||||
|
||||
# To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up
|
||||
storage:
|
||||
container_name: supabase-storage
|
||||
image: supabase/storage-api:v1.60.4
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
rest:
|
||||
condition: service_started
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://storage:5000/status"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
POSTGREST_URL: http://rest:3000
|
||||
|
||||
# Legacy symmetric HS256 key
|
||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
# JWKS for token verification (EC public + legacy symmetric).
|
||||
# For Podman, use: JWT_JWKS: ${JWT_JWKS}
|
||||
#JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
|
||||
|
||||
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||
FILE_SIZE_LIMIT: 52428800
|
||||
STORAGE_BACKEND: file
|
||||
# S3 bucket when using S3 backend, directory name when using 'file'
|
||||
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
|
||||
# S3 Backend configuration
|
||||
#GLOBAL_S3_ENDPOINT: https://your-s3-endpoint
|
||||
#GLOBAL_S3_PROTOCOL: https
|
||||
#GLOBAL_S3_FORCE_PATH_STYLE: "true"
|
||||
#AWS_ACCESS_KEY_ID: your-access-key-id
|
||||
#AWS_SECRET_ACCESS_KEY: your-secret-access-key
|
||||
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||
TENANT_ID: ${STORAGE_TENANT_ID}
|
||||
# TODO: https://github.com/supabase/storage-api/issues/55
|
||||
REGION: ${REGION}
|
||||
ENABLE_IMAGE_TRANSFORMATION: "true"
|
||||
IMGPROXY_URL: http://imgproxy:5001
|
||||
# S3 protocol endpoint configuration
|
||||
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
|
||||
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
|
||||
imgproxy:
|
||||
container_name: supabase-imgproxy
|
||||
image: darthsim/imgproxy:v3.30.1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"imgproxy",
|
||||
"health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
IMGPROXY_BIND: ":5001"
|
||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
|
||||
|
||||
meta:
|
||||
container_name: supabase-meta
|
||||
image: supabase/postgres-meta:v0.96.6
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PG_META_PORT: 8080
|
||||
PG_META_DB_HOST: ${POSTGRES_HOST}
|
||||
PG_META_DB_PORT: ${POSTGRES_PORT}
|
||||
PG_META_DB_NAME: ${POSTGRES_DB}
|
||||
PG_META_DB_USER: postgres
|
||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||
|
||||
functions:
|
||||
container_name: supabase-edge-functions
|
||||
image: supabase/edge-runtime:v1.74.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/functions:/home/deno/functions:z
|
||||
- deno-cache:/root/.cache/deno
|
||||
depends_on:
|
||||
api-gw:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/9000'"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
# Legacy symmetric HS256 key
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
# JWKS for token verification (EC public + legacy symmetric).
|
||||
# For Podman, use: SUPABASE_JWKS: ${JWT_JWKS}
|
||||
#SUPABASE_JWKS: ${JWT_JWKS:-{"keys":[]}}
|
||||
|
||||
SUPABASE_URL: http://api-gw:8000
|
||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
# Legacy API keys (HS256-signed JWTs)
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
# New opaque API keys
|
||||
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
|
||||
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
|
||||
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
# TODO: Allow configuring VERIFY_JWT per function.
|
||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||
command:
|
||||
[
|
||||
"start",
|
||||
"--main-service",
|
||||
"/home/deno/functions/main"
|
||||
]
|
||||
|
||||
# Comment out everything below this point if you are using an external Postgres database
|
||||
db:
|
||||
container_name: supabase-db
|
||||
# To upgrade an existing Postgres 15 database in place, see utils/upgrade-pg17.sh.
|
||||
image: supabase/postgres:17.6.1.136
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
|
||||
# Must be superuser to create event trigger
|
||||
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
|
||||
# Must be superuser to alter reserved role
|
||||
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
|
||||
# Initialize the database settings with JWT_SECRET and JWT_EXP
|
||||
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
|
||||
# PGDATA directory is persisted between restarts
|
||||
- ./volumes/db/data:/var/lib/postgresql/data:Z
|
||||
# Changes required for internal supabase data such as _analytics
|
||||
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
|
||||
# Changes required for Analytics support
|
||||
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
|
||||
# Changes required for Pooler support
|
||||
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
|
||||
# Use named volume to persist pgsodium decryption key between restarts
|
||||
- db-config:/etc/postgresql-custom
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"pg_isready",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-h",
|
||||
"localhost"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
environment:
|
||||
POSTGRES_HOST: /var/run/postgresql
|
||||
PGPORT: ${POSTGRES_PORT}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXP: ${JWT_EXPIRY}
|
||||
command:
|
||||
[
|
||||
"postgres",
|
||||
"-c",
|
||||
"config_file=/etc/postgresql/postgresql.conf",
|
||||
"-c",
|
||||
"log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs
|
||||
]
|
||||
|
||||
# Update the DATABASE_URL if you are using an external Postgres database
|
||||
supavisor:
|
||||
container_name: supabase-pooler
|
||||
image: supabase/supavisor:2.9.5
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${POSTGRES_PORT}:5432
|
||||
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
|
||||
volumes:
|
||||
- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"-sSfL",
|
||||
"--head",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"http://127.0.0.1:4000/api/health"
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: 4000
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||
CLUSTER_POSTGRES: "true"
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
METRICS_JWT_SECRET: ${JWT_SECRET}
|
||||
REGION: local
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
|
||||
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
|
||||
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
|
||||
POOLER_POOL_MODE: transaction
|
||||
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
|
||||
command:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"
|
||||
]
|
||||
|
||||
volumes:
|
||||
db-config:
|
||||
deno-cache:
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
auto_confirm=0
|
||||
|
||||
confirm () {
|
||||
if [ "$auto_confirm" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf "Are you sure you want to proceed? (y/N) "
|
||||
read -r REPLY
|
||||
case "$REPLY" in
|
||||
[Yy])
|
||||
;;
|
||||
*)
|
||||
echo "Script canceled."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$1" = "-y" ]; then
|
||||
auto_confirm=1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "*** WARNING: This will remove all containers and container data, and optionally reset .env ***"
|
||||
echo ""
|
||||
|
||||
confirm
|
||||
|
||||
echo "===> Stopping and removing all containers..."
|
||||
|
||||
if [ -f ".env" ]; then
|
||||
docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
|
||||
elif [ -f ".env.example" ]; then
|
||||
echo "No .env found, using .env.example for docker compose down..."
|
||||
docker compose --env-file .env.example -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
|
||||
else
|
||||
echo "Skipping 'docker compose down' because there's no env-file."
|
||||
fi
|
||||
|
||||
echo "===> Cleaning up bind-mounted directories..."
|
||||
BIND_MOUNTS="./volumes/db/data ./volumes/storage"
|
||||
|
||||
for dir in $BIND_MOUNTS; do
|
||||
if [ -d "$dir" ]; then
|
||||
echo "Removing $dir..."
|
||||
confirm
|
||||
rm -rf "$dir"
|
||||
else
|
||||
echo "$dir not found."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "===> Resetting .env file (will save backup to .env.old)..."
|
||||
confirm
|
||||
if [ -f ".env" ] || [ -L ".env" ]; then
|
||||
echo "Renaming existing .env file to .env.old"
|
||||
mv .env .env.old
|
||||
else
|
||||
echo "No .env file found."
|
||||
fi
|
||||
|
||||
if [ -f ".env.example" ]; then
|
||||
echo "===> Copying .env.example to .env"
|
||||
cp .env.example .env
|
||||
else
|
||||
echo "No .env.example found, can't restore .env to default values."
|
||||
fi
|
||||
|
||||
echo "Cleanup complete!"
|
||||
echo "Re-run 'docker compose pull' to update images."
|
||||
echo ""
|
||||
@@ -1,297 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Manage the self-hosted Supabase docker compose stack.
|
||||
#
|
||||
# Override files are layered via docker compose's native COMPOSE_FILE env
|
||||
# var in .env. Format: colon-separated list with docker-compose.yml first.
|
||||
#
|
||||
# Examples in .env:
|
||||
# COMPOSE_FILE=docker-compose.yml
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
|
||||
#
|
||||
# Manage with: sh run.sh config add <name> | config remove <name>
|
||||
# (accepts either a short name like 'pg17' or 'docker-compose.pg17.yml')
|
||||
#
|
||||
# Usage:
|
||||
# sh run.sh start # docker compose up -d --wait
|
||||
# sh run.sh stop # docker compose down
|
||||
# sh run.sh restart [service] # restart the stack (or named services)
|
||||
# sh run.sh restart --except <svc>... # restart all services except the named ones
|
||||
# sh run.sh recreate [service] # stop then start (or force-recreate one service)
|
||||
# sh run.sh recreate --except <svc>... # force-recreate all services except the named ones
|
||||
# sh run.sh status # docker compose ps
|
||||
# sh run.sh logs [service] # follow logs (all or one service)
|
||||
# sh run.sh inspect <service> # docker inspect on a service's container
|
||||
# sh run.sh printenv <service> # print a service's environment variables
|
||||
# sh run.sh pull # pull images
|
||||
# sh run.sh config # show the active COMPOSE_FILE list
|
||||
# sh run.sh config add <name> # add an override to COMPOSE_FILE in .env
|
||||
# sh run.sh config remove <name> # remove an override from COMPOSE_FILE in .env
|
||||
# sh run.sh compose-config # dump fully-resolved docker compose config
|
||||
# sh run.sh secrets # print key passwords and API keys from .env
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
echo "ERROR: docker-compose.yml not found in $(pwd)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Normalize an override argument:
|
||||
# pg17 -> docker-compose.pg17.yml
|
||||
# docker-compose.pg17.yml -> docker-compose.pg17.yml
|
||||
# ./docker-compose.pg17.yml -> docker-compose.pg17.yml
|
||||
# docker-compose.yml -> error (base file, always implicit)
|
||||
normalize_override() {
|
||||
arg="${1#./}"
|
||||
case "$arg" in
|
||||
docker-compose.yml)
|
||||
echo "ERROR: docker-compose.yml is the base file, always included" >&2
|
||||
return 1
|
||||
;;
|
||||
docker-compose.*.yml)
|
||||
echo "$arg"
|
||||
;;
|
||||
*)
|
||||
echo "docker-compose.${arg}.yml"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Read COMPOSE_FILE from .env (stripping quotes and CR).
|
||||
read_compose_file() {
|
||||
[ -f .env ] || return 0
|
||||
grep '^COMPOSE_FILE=' .env | head -n1 | cut -d= -f2- | tr -d "\r\"'"
|
||||
}
|
||||
|
||||
# Pretty-print the effective compose file list.
|
||||
print_config() {
|
||||
val="$1"
|
||||
[ -z "$val" ] && val="docker-compose.yml"
|
||||
echo "COMPOSE_FILE=$val"
|
||||
echo "compose files:"
|
||||
OLD_IFS=$IFS
|
||||
IFS=:
|
||||
for f in $val; do
|
||||
echo " $f"
|
||||
done
|
||||
IFS=$OLD_IFS
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Update or append COMPOSE_FILE in .env.
|
||||
write_compose_file() {
|
||||
new_value="$1"
|
||||
if [ ! -f .env ]; then
|
||||
echo "ERROR: .env not found in $(pwd)" >&2
|
||||
exit 1
|
||||
fi
|
||||
new_line="COMPOSE_FILE=$new_value"
|
||||
if grep -q '^COMPOSE_FILE=' .env; then
|
||||
sed -i.old -e "s|^COMPOSE_FILE=.*$|$new_line|" .env
|
||||
rm -f .env.old
|
||||
else
|
||||
cat >> .env <<EOF
|
||||
|
||||
############
|
||||
# Docker compose override files to layer on top of docker-compose.yml.
|
||||
# Colon-separated list. Manage with: sh run.sh config add|remove <name>.
|
||||
#
|
||||
# Examples:
|
||||
# COMPOSE_FILE=docker-compose.yml
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
|
||||
############
|
||||
$new_line
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Echoes the list of services (one per line) minus those passed as args.
|
||||
# Warns on unknown names; returns 1 if no services remain.
|
||||
services_except() {
|
||||
all_services=$(docker compose config --services)
|
||||
filtered="$all_services"
|
||||
for ex in "$@"; do
|
||||
echo "$all_services" | grep -qFx "$ex" \
|
||||
|| echo "Warning: '$ex' is not a service in this project" >&2
|
||||
filtered=$(echo "$filtered" | grep -vFx "$ex" || true)
|
||||
done
|
||||
if [ -z "$filtered" ]; then
|
||||
echo "No services left after applying --except" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$filtered"
|
||||
}
|
||||
|
||||
CMD="${1:-help}"
|
||||
[ "$#" -gt 0 ] && shift
|
||||
|
||||
case "$CMD" in
|
||||
start|up)
|
||||
exec docker compose up -d --wait "$@"
|
||||
;;
|
||||
stop|down)
|
||||
exec docker compose down "$@"
|
||||
;;
|
||||
restart)
|
||||
if [ "${1:-}" = "--except" ]; then
|
||||
shift
|
||||
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") restart --except <svc>..." >&2; exit 1; }
|
||||
services=$(services_except "$@") || exit 1
|
||||
# shellcheck disable=SC2086
|
||||
exec docker compose restart $services
|
||||
fi
|
||||
exec docker compose restart "$@"
|
||||
;;
|
||||
recreate)
|
||||
if [ "${1:-}" = "--except" ]; then
|
||||
shift
|
||||
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") recreate --except <svc>..." >&2; exit 1; }
|
||||
services=$(services_except "$@") || exit 1
|
||||
# shellcheck disable=SC2086
|
||||
exec docker compose up -d --wait --force-recreate --no-deps $services
|
||||
fi
|
||||
if [ $# -eq 0 ]; then
|
||||
docker compose down
|
||||
exec docker compose up -d --wait
|
||||
fi
|
||||
# Single-service recreate: force-recreate the named services only,
|
||||
# leave their dependencies running.
|
||||
exec docker compose up -d --wait --force-recreate --no-deps "$@"
|
||||
;;
|
||||
status|ps)
|
||||
exec docker compose ps "$@"
|
||||
;;
|
||||
logs)
|
||||
exec docker compose logs -f "$@"
|
||||
;;
|
||||
inspect)
|
||||
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") inspect <service> [docker-inspect-args]" >&2; exit 1; }
|
||||
svc="$1"; shift
|
||||
cid=$(docker compose ps -q "$svc")
|
||||
[ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; }
|
||||
exec docker inspect "$cid" "$@"
|
||||
;;
|
||||
printenv)
|
||||
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") printenv <service>" >&2; exit 1; }
|
||||
svc="$1"
|
||||
cid=$(docker compose ps -q "$svc")
|
||||
[ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; }
|
||||
exec docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$cid"
|
||||
;;
|
||||
pull)
|
||||
exec docker compose pull "$@"
|
||||
;;
|
||||
compose-config)
|
||||
exec docker compose config "$@"
|
||||
;;
|
||||
config)
|
||||
sub="${1:-show}"
|
||||
[ "$#" -gt 0 ] && shift
|
||||
current=$(read_compose_file)
|
||||
case "$sub" in
|
||||
show)
|
||||
print_config "$current"
|
||||
;;
|
||||
add)
|
||||
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") config add <name>..." >&2; exit 1; }
|
||||
new_value="${current:-docker-compose.yml}"
|
||||
changed=false
|
||||
for arg in "$@"; do
|
||||
file=$(normalize_override "$arg") || exit 1
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "ERROR: $file not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
case ":$new_value:" in
|
||||
*":$file:"*) echo "Already present: $file" ;;
|
||||
*) new_value="$new_value:$file"; changed=true ;;
|
||||
esac
|
||||
done
|
||||
[ "$changed" = true ] && write_compose_file "$new_value"
|
||||
print_config "$new_value"
|
||||
;;
|
||||
remove|rm)
|
||||
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") config remove <name>..." >&2; exit 1; }
|
||||
new_value="${current:-docker-compose.yml}"
|
||||
changed=false
|
||||
for arg in "$@"; do
|
||||
file=$(normalize_override "$arg") || exit 1
|
||||
case ":$new_value:" in
|
||||
*":$file:"*)
|
||||
# Drop $file by rebuilding the colon list
|
||||
tmp=""
|
||||
OLD_IFS=$IFS
|
||||
IFS=:
|
||||
for tok in $new_value; do
|
||||
[ "$tok" = "$file" ] || tmp="${tmp:+$tmp:}$tok"
|
||||
done
|
||||
IFS=$OLD_IFS
|
||||
new_value="$tmp"
|
||||
changed=true
|
||||
;;
|
||||
*) echo "Not present: $file" ;;
|
||||
esac
|
||||
done
|
||||
[ "$changed" = true ] && write_compose_file "$new_value"
|
||||
print_config "$new_value"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown config subcommand: $sub" >&2
|
||||
echo "Use: config | config add <name>... | config remove <name>..." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
secrets)
|
||||
if [ ! -f .env ]; then
|
||||
echo "ERROR: .env not found in $(pwd)" >&2
|
||||
exit 1
|
||||
fi
|
||||
for var in POSTGRES_PASSWORD DASHBOARD_PASSWORD \
|
||||
SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY \
|
||||
S3_PROTOCOL_ACCESS_KEY_ID S3_PROTOCOL_ACCESS_KEY_SECRET; do
|
||||
line=$(grep "^${var}=" .env | head -n1)
|
||||
if [ -n "$line" ]; then
|
||||
echo "$line"
|
||||
else
|
||||
echo "${var}="
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
;;
|
||||
help|-h|--help)
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command>
|
||||
|
||||
Commands:
|
||||
start Start the stack (docker compose up -d --wait)
|
||||
stop Stop the stack (docker compose down)
|
||||
restart [service] Restart the stack (or named services)
|
||||
restart --except <svc>...
|
||||
Restart all services except the named ones
|
||||
recreate [service] Stop then start, or force-recreate one service (--no-deps)
|
||||
recreate --except <svc>...
|
||||
Force-recreate all services except the named ones (--no-deps)
|
||||
status Show service status
|
||||
logs [service] Follow logs (optionally for a single service)
|
||||
inspect <service> Inspect a service's container (forwards extra args to docker inspect)
|
||||
printenv <service> Print a service's environment variables (one per line)
|
||||
pull Pull all images
|
||||
config Show the active COMPOSE_FILE list
|
||||
config add <name> Add an override to COMPOSE_FILE in .env (short name or full filename)
|
||||
config remove <name> Remove an override from COMPOSE_FILE in .env
|
||||
compose-config Dump the fully-resolved docker compose config
|
||||
secrets Show key passwords and API keys from .env
|
||||
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $CMD" >&2
|
||||
echo "Run '$0 help' for usage." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,455 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora).
|
||||
#
|
||||
# What it does:
|
||||
# 1. Installs prerequisites: git, openssl, jq, ca-certificates
|
||||
# 2. Installs Docker Engine + Compose plugin (if missing)
|
||||
# 3. Optionally installs the AWS CLI v2 (--with-aws)
|
||||
# 4. Sparse-clones the repo to extract the contents of ./docker
|
||||
# 5. Creates a project directory in CWD and copies docker/* into it
|
||||
# 6. Records the base version the deployment was set up from (.supabase-version)
|
||||
# 7. Prompts for the main URLs and writes them to .env
|
||||
# 8. Generates secrets and asymmetric API keys via utils/*.sh
|
||||
#
|
||||
# Usage:
|
||||
# sh setup.sh # interactive
|
||||
# sh setup.sh -y # accept defaults, no prompts
|
||||
# sh setup.sh --project-dir my-supabase # name the project directory
|
||||
# sh setup.sh --skip-deps # skip system-package installation
|
||||
# sh setup.sh --with-aws # also install the AWS CLI v2
|
||||
# sh setup.sh --ref self-hosted/v0.7.0 # clone docker/ from a specific git ref
|
||||
# sh setup.sh --head # clone docker/ from HEAD (skip tags)
|
||||
#
|
||||
# curl -fsSL <url-to-this-script> | sh # bootstrap from scratch in CWD
|
||||
#
|
||||
# By default the docker/ sources are cloned from the latest self-hosted release
|
||||
# tag (self-hosted/v*), falling back to the default branch (HEAD) if none exist.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="supabase-project"
|
||||
SKIP_DEPS=0
|
||||
WITH_AWS=0
|
||||
ASSUME_YES=0
|
||||
SOURCE_REF=""
|
||||
FORCE_HEAD=0
|
||||
|
||||
print_help() {
|
||||
cat <<EOF
|
||||
Usage: setup.sh [options]
|
||||
|
||||
Options:
|
||||
-p, --project-dir <name> Name of the project directory (default: supabase-project)
|
||||
--skip-deps Skip installation of system packages
|
||||
--with-aws Install the AWS CLI v2
|
||||
--ref <tag|branch> Clone docker/ from this git ref instead of the
|
||||
latest self-hosted tag (no HEAD fallback)
|
||||
--head Clone docker/ from the default branch (HEAD),
|
||||
skipping self-hosted tag detection
|
||||
-y, --yes Non-interactive: accept defaults, no prompts
|
||||
-h, --help Show this help and exit
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-p|--project-dir) PROJECT_DIR="$2"; shift 2 ;;
|
||||
--skip-deps) SKIP_DEPS=1; shift ;;
|
||||
--with-aws) WITH_AWS=1; shift ;;
|
||||
--ref) SOURCE_REF="$2"; shift 2 ;;
|
||||
--head) FORCE_HEAD=1; shift ;;
|
||||
-y|--yes) ASSUME_YES=1; shift ;;
|
||||
-h|--help) print_help; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; print_help; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Interactive vs not: -y forces non-interactive; otherwise we're non-interactive
|
||||
# when there's no controlling terminal to prompt on (e.g. curl | sh in CI).
|
||||
if [ "$ASSUME_YES" = "1" ] || ! ( : < /dev/tty ) 2>/dev/null; then
|
||||
NON_INTERACTIVE=1
|
||||
else
|
||||
NON_INTERACTIVE=0
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
log() { printf "===> %s\n" "$*"; }
|
||||
warn() { printf "WARNING: %s\n" "$*" >&2; }
|
||||
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
# Prompt with a default; echoes the chosen value on stdout.
|
||||
# Reads from /dev/tty so prompts work even when stdin is a pipe (curl | sh).
|
||||
# Falls back to the default with -y or when no controlling terminal exists.
|
||||
ask() {
|
||||
# ask <prompt> <default> -> echoes chosen value
|
||||
if [ "$NON_INTERACTIVE" = "1" ]; then
|
||||
printf '%s' "$2"
|
||||
return
|
||||
fi
|
||||
printf "%s [%s]: " "$1" "$2" > /dev/tty
|
||||
read -r reply < /dev/tty
|
||||
[ -z "$reply" ] && reply="$2"
|
||||
printf '%s' "$reply"
|
||||
}
|
||||
|
||||
# True if the value looks like an http(s) URL (scheme check only, not validation).
|
||||
valid_url() {
|
||||
case "$1" in
|
||||
http://?*|https://?*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Like ask, but requires an http(s):// value. Re-prompts interactively; with no
|
||||
# usable terminal (-y or curl | sh) a bad value is fatal rather than silently kept.
|
||||
ask_url() {
|
||||
# ask_url <prompt> <default> -> echoes a validated URL
|
||||
while :; do
|
||||
_url=$(ask "$1" "$2")
|
||||
valid_url "$_url" && { printf '%s' "$_url"; return 0; }
|
||||
if [ "$NON_INTERACTIVE" = "1" ]; then
|
||||
die "$1 must start with http:// or https:// (got: '$_url')"
|
||||
fi
|
||||
printf " '%s' is not a URL - it must start with http:// or https://.\n" "$_url" > /dev/tty
|
||||
done
|
||||
}
|
||||
|
||||
OS_FAMILY=""
|
||||
OS_ID=""
|
||||
OS_CODENAME=""
|
||||
|
||||
detect_os() {
|
||||
[ -f /etc/os-release ] || die "Cannot detect OS: /etc/os-release missing. Linux only."
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
OS_CODENAME="${VERSION_CODENAME:-}"
|
||||
case "$ID" in
|
||||
ubuntu|debian) OS_FAMILY="debian" ;;
|
||||
centos|rhel|fedora|rocky|almalinux|ol|amzn) OS_FAMILY="rhel" ;;
|
||||
*)
|
||||
case "${ID_LIKE:-}" in
|
||||
*debian*|*ubuntu*) OS_FAMILY="debian" ;;
|
||||
*rhel*|*fedora*|*centos*) OS_FAMILY="rhel" ;;
|
||||
*) die "Unsupported distribution: $ID" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
log "Detected OS: $ID ($OS_FAMILY)"
|
||||
}
|
||||
|
||||
pkg_update() {
|
||||
if [ "$OS_FAMILY" = "debian" ]; then
|
||||
$SUDO apt-get update -qq -y
|
||||
else
|
||||
$SUDO dnf makecache -q -y || true
|
||||
fi
|
||||
}
|
||||
|
||||
pkg_install() {
|
||||
if [ "$OS_FAMILY" = "debian" ]; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
$SUDO apt-get install -qq -y "$@"
|
||||
else
|
||||
$SUDO dnf install -q -y "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
install_base_packages() {
|
||||
log "Installing base packages: git, openssl, jq, ca-certificates"
|
||||
pkg_update
|
||||
if [ "$OS_FAMILY" = "debian" ]; then
|
||||
pkg_install git openssl jq ca-certificates \
|
||||
apt-transport-https gnupg lsb-release
|
||||
else
|
||||
pkg_install git openssl jq ca-certificates dnf-plugins-core
|
||||
fi
|
||||
}
|
||||
|
||||
docker_present() {
|
||||
command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
if docker_present; then
|
||||
log "Docker already installed: $(docker --version)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Installing Docker Engine and Compose plugin"
|
||||
if [ "$OS_FAMILY" = "debian" ]; then
|
||||
$SUDO install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" \
|
||||
| $SUDO gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
|
||||
$SUDO chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
codename="${OS_CODENAME:-$(lsb_release -cs 2>/dev/null || echo stable)}"
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable" \
|
||||
| $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
$SUDO apt-get update -y
|
||||
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
else
|
||||
# Amazon Linux
|
||||
if [ "$OS_ID" = "amzn" ]; then
|
||||
# Install Docker from the repo
|
||||
pkg_install docker
|
||||
# Install Docker Compose
|
||||
$SUDO mkdir -p /usr/local/lib/docker/cli-plugins && \
|
||||
$SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \
|
||||
-o /usr/local/lib/docker/cli-plugins/docker-compose && \
|
||||
$SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
||||
else
|
||||
repo_distro="centos"
|
||||
case "$OS_ID" in
|
||||
fedora) repo_distro="fedora" ;;
|
||||
rhel) repo_distro="rhel" ;;
|
||||
esac
|
||||
$SUDO dnf config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" 2>/dev/null \
|
||||
|| $SUDO dnf-3 config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo"
|
||||
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Enabling and starting docker service"
|
||||
$SUDO systemctl enable --now docker || warn "Could not enable docker via systemctl; start it manually."
|
||||
|
||||
docker_present || die "Docker installation finished but 'docker compose' is still unavailable."
|
||||
}
|
||||
|
||||
install_aws() {
|
||||
if command -v aws >/dev/null 2>&1; then
|
||||
log "AWS CLI already installed: $(aws --version 2>&1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64) aws_arch="x86_64" ;;
|
||||
aarch64|arm64) aws_arch="aarch64" ;;
|
||||
*) die "Unsupported architecture for AWS CLI: $arch" ;;
|
||||
esac
|
||||
|
||||
log "Installing AWS CLI v2 (${aws_arch})"
|
||||
command -v unzip >/dev/null 2>&1 || pkg_install unzip
|
||||
tmp=$(mktemp -d)
|
||||
(
|
||||
cd "$tmp"
|
||||
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}.zip" -o awscliv2.zip
|
||||
unzip -q -o awscliv2.zip
|
||||
$SUDO ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
|
||||
)
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
SRC_DIR=""
|
||||
SRC_TMP=""
|
||||
RESOLVED_REF=""
|
||||
REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}"
|
||||
STAMP_FILE=".supabase-version"
|
||||
|
||||
# Highest self-hosted/v* tag on the remote, or empty when the remote has none.
|
||||
# Returns non-zero (printing nothing) when the remote can't be reached.
|
||||
latest_release_tag() {
|
||||
_refs=$(git ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || return 1
|
||||
printf '%s\n' "$_refs" \
|
||||
| sed 's#^.*refs/tags/##' \
|
||||
| grep -E '^self-hosted/v[0-9]' \
|
||||
| sort -V | tail -n1
|
||||
}
|
||||
|
||||
# Clone only docker/ from <ref> (empty = default branch) into <dest>.
|
||||
sparse_clone() {
|
||||
# sparse_clone <dest> [ref]
|
||||
_dest="$1"
|
||||
_ref="$2"
|
||||
if [ -n "$_ref" ]; then
|
||||
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
|
||||
--branch "$_ref" "$REPO_URL" "$_dest" 2>/dev/null || return 1
|
||||
else
|
||||
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
|
||||
"$REPO_URL" "$_dest" 2>/dev/null || return 1
|
||||
fi
|
||||
( cd "$_dest" &&
|
||||
git sparse-checkout init --cone &&
|
||||
git sparse-checkout set docker &&
|
||||
git checkout --quiet ) 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# Echo the commit the clone resolved to (for stamping HEAD-based checkouts).
|
||||
resolved_sha() {
|
||||
git -C "$1" rev-parse HEAD 2>/dev/null || true
|
||||
}
|
||||
|
||||
prepare_source() {
|
||||
SRC_TMP=$(mktemp -d) || die "Could not create a temporary directory"
|
||||
dest="$SRC_TMP/supabase"
|
||||
|
||||
# Pick the ref to clone; empty means the default branch (HEAD), used only
|
||||
# when no release tag exists yet (or --head). A resolved tag that fails to
|
||||
# clone is an error, not a reason to silently install HEAD instead.
|
||||
if [ -n "$SOURCE_REF" ]; then
|
||||
ref="$SOURCE_REF"
|
||||
elif [ "$FORCE_HEAD" = "1" ]; then
|
||||
ref=""
|
||||
else
|
||||
if ref=$(latest_release_tag); then
|
||||
[ -n "$ref" ] || log "No self-hosted release tag found; using the default branch (HEAD)"
|
||||
else
|
||||
die "Could not reach $REPO_URL to look up release tags. Check your network and retry, or pass --head (default branch) or --ref <tag>."
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Sparse-cloning supabase repo at ${ref:-HEAD}"
|
||||
sparse_clone "$dest" "$ref" || die "Could not clone '${ref:-HEAD}' from $REPO_URL"
|
||||
|
||||
# Stamp the tag name for a release tag, else the exact commit SHA.
|
||||
case "$ref" in
|
||||
self-hosted/v*) RESOLVED_REF="$ref" ;;
|
||||
*) RESOLVED_REF=$(resolved_sha "$dest") ;;
|
||||
esac
|
||||
|
||||
SRC_DIR="$dest/docker"
|
||||
}
|
||||
|
||||
cleanup_src_tmp() {
|
||||
if [ -n "$SRC_TMP" ] && [ -d "$SRC_TMP" ]; then
|
||||
rm -rf "$SRC_TMP"
|
||||
fi
|
||||
}
|
||||
trap cleanup_src_tmp EXIT
|
||||
|
||||
read_env() {
|
||||
grep "^$1=" .env 2>/dev/null | head -n1 | cut -d= -f2-
|
||||
}
|
||||
|
||||
# Record the base version this deployment was set up from, so update.sh can
|
||||
# 3-way merge future upgrades against it: it fetches the snapshot at this ref
|
||||
# as the merge base, and derives the base version from the ref itself. Just the
|
||||
# ref - per-deployment state, not vendor content: gitignored.
|
||||
write_version_stamp() {
|
||||
[ -n "$1" ] || { warn "Could not resolve a base ref; skipping $STAMP_FILE"; return 0; }
|
||||
{
|
||||
echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh."
|
||||
echo "# Do not commit or edit by hand. Records the ref this deployment was based on."
|
||||
echo "ref=$1"
|
||||
} > "$STAMP_FILE"
|
||||
log "Recorded base version in $STAMP_FILE (ref=$1)"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
log "Setup starting in $(pwd)"
|
||||
log "This may take several minutes..."
|
||||
|
||||
if [ "$SKIP_DEPS" = "1" ]; then
|
||||
log "Skipping system-package installation (--skip-deps)"
|
||||
else
|
||||
detect_os
|
||||
install_base_packages
|
||||
install_docker
|
||||
fi
|
||||
|
||||
if [ "$WITH_AWS" = "1" ]; then
|
||||
install_aws
|
||||
fi
|
||||
|
||||
# Idempotent re-run: if CWD is already a set-up project, skip bootstrap.
|
||||
# A clone has docker-compose.yml + utils/ but only .env.example;
|
||||
# a set-up project also has a real .env.
|
||||
if [ -f .env ] && [ -f docker-compose.yml ] && [ -d utils ]; then
|
||||
log "Already in a Supabase project directory; skipping bootstrap."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
prepare_source
|
||||
|
||||
target="$(pwd)/$PROJECT_DIR"
|
||||
if [ -e "$target" ]; then
|
||||
die "Target $target already exists. Pick a different name with --project-dir"
|
||||
fi
|
||||
|
||||
log "Creating project at $target"
|
||||
mkdir -p "$target"
|
||||
cp -rf "$SRC_DIR/." "$target/"
|
||||
if [ -f "$target/.env.example" ] && [ ! -f "$target/.env" ]; then
|
||||
cp "$target/.env.example" "$target/.env"
|
||||
fi
|
||||
|
||||
cd "$target"
|
||||
|
||||
current_public_url=$(read_env SUPABASE_PUBLIC_URL)
|
||||
current_site_url=$(read_env SITE_URL)
|
||||
|
||||
[ -z "$current_public_url" ] && current_public_url="http://localhost:8000"
|
||||
[ -z "$current_site_url" ] && current_site_url="http://localhost:3000"
|
||||
|
||||
if [ "$NON_INTERACTIVE" = "1" ]; then
|
||||
log "Non-interactive: using default URLs (edit .env to change)"
|
||||
else
|
||||
echo ""
|
||||
echo "Configure the main URLs (press Enter to accept the default)."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
public_url=$(ask_url "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url")
|
||||
api_url=$(ask_url "API_EXTERNAL_URL (Auth callbacks)" "$public_url/auth/v1")
|
||||
site_url=$(ask_url "SITE_URL (default Auth redirect)" "$current_site_url")
|
||||
|
||||
# Suggest PROXY_DOMAIN from the public_url host (unless it's localhost-ish)
|
||||
public_host=$(printf '%s' "$public_url" | sed -e 's|^https*://||' -e 's|/.*$||' -e 's|:.*$||')
|
||||
case "$public_host" in
|
||||
localhost|127.*|"") current_proxy_domain=$(read_env PROXY_DOMAIN) ;;
|
||||
*) current_proxy_domain="$public_host" ;;
|
||||
esac
|
||||
[ -z "$current_proxy_domain" ] && current_proxy_domain="your-domain.example.com"
|
||||
|
||||
proxy_domain=$(ask "PROXY_DOMAIN (for nginx/caddy HTTPS proxy)" "$current_proxy_domain")
|
||||
|
||||
# Derive CERTBOT_EMAIL = admin@<last-two-labels-of-proxy-domain>.
|
||||
# Naive: doesn't handle ccTLDs like .co.uk; user can edit .env after.
|
||||
domain_root=$(printf '%s' "$proxy_domain" | awk -F. 'NF>=2 { print $(NF-1)"."$NF; next } { print }')
|
||||
certbot_email="admin@${domain_root}"
|
||||
log "Setting CERTBOT_EMAIL=${certbot_email}"
|
||||
|
||||
sed -i.old \
|
||||
-e "s|^SUPABASE_PUBLIC_URL=.*$|SUPABASE_PUBLIC_URL=${public_url}|" \
|
||||
-e "s|^API_EXTERNAL_URL=.*$|API_EXTERNAL_URL=${api_url}|" \
|
||||
-e "s|^SITE_URL=.*$|SITE_URL=${site_url}|" \
|
||||
-e "s|^PROXY_DOMAIN=.*$|PROXY_DOMAIN=${proxy_domain}|" \
|
||||
-e "s|^CERTBOT_EMAIL=.*$|CERTBOT_EMAIL=${certbot_email}|" \
|
||||
.env
|
||||
rm -f .env.old
|
||||
|
||||
log "Generating secrets and legacy API keys"
|
||||
sh utils/generate-keys.sh --update-env
|
||||
|
||||
log "Generating asymmetric key pair and opaque API keys"
|
||||
sh utils/add-new-auth-keys.sh --update-env
|
||||
|
||||
write_version_stamp "$RESOLVED_REF"
|
||||
|
||||
log "Pulling Docker images"
|
||||
if [ "$NON_INTERACTIVE" = "1" ]; then
|
||||
docker compose --progress quiet pull || warn "docker compose pull failed; you can retry later."
|
||||
else
|
||||
docker compose pull || warn "docker compose pull failed; you can retry later."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Setup complete. Project ready at: $(pwd)"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " cd $(pwd)"
|
||||
echo " sh run.sh config"
|
||||
echo " sh run.sh secrets"
|
||||
echo " sh run.sh start"
|
||||
echo ""
|
||||
echo "To enable docker-compose overrides (caddy, nginx, logs, rustfs, s3, kong):"
|
||||
echo " sh run.sh config add caddy"
|
||||
echo ""
|
||||
@@ -1,11 +0,0 @@
|
||||
# Test override: exposes the S3 backend port for direct testing.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.rustfs.yml \
|
||||
# -f ./tests/docker-compose.rustfs.test.yml up -d
|
||||
#
|
||||
|
||||
services:
|
||||
rustfs:
|
||||
ports:
|
||||
- "${S3_BACKEND_TEST_PORT:-9100}:9000"
|
||||
@@ -1,13 +0,0 @@
|
||||
# Test override: exposes the S3 backend port for direct testing.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
|
||||
# -f ./tests/docker-compose.s3.test.yml up -d
|
||||
#
|
||||
# When swapping to a different S3 backend (e.g. RustFS), update the
|
||||
# service name and internal port to match the new backend.
|
||||
|
||||
services:
|
||||
minio:
|
||||
ports:
|
||||
- "${S3_BACKEND_TEST_PORT:-9100}:9000"
|
||||
@@ -1,398 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Test API key types and asymmetric auth against a running self-hosted instance.
|
||||
#
|
||||
# Usage:
|
||||
# sh test-auth-keys.sh # Uses http://localhost:8000
|
||||
# sh test-auth-keys.sh <base_url> # Custom URL
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Running self-hosted Supabase instance
|
||||
# - .env file with all keys configured
|
||||
# - jq (for JSON parsing)
|
||||
# - node >= 16 (for HS256 token minting test only)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
BASE_URL="${1:-http://localhost:8000}"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run from the project directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in jq node; do
|
||||
if ! command -v $cmd >/dev/null 2>&1; then
|
||||
echo "Error: $cmd not found."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Read keys from .env
|
||||
JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-)
|
||||
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
|
||||
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
|
||||
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
|
||||
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
check() {
|
||||
test_name="$1"
|
||||
expected="$2"
|
||||
actual="$3"
|
||||
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo " PASS: $test_name (HTTP $actual)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: $test_name (expected $expected, got $actual)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
http_status() {
|
||||
url="$1"
|
||||
shift
|
||||
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Testing against $BASE_URL ==="
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------
|
||||
# 1. Route tests with API key types
|
||||
# ---------------------------------------------
|
||||
|
||||
echo "--- REST API (/rest/v1/) ---"
|
||||
check "Legacy ANON_KEY -> 403" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")"
|
||||
check "Legacy SERVICE_ROLE_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "New PUBLISHABLE_KEY -> 403" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
|
||||
check "New SECRET_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")"
|
||||
else
|
||||
echo " SKIP: Opaque keys not configured"
|
||||
fi
|
||||
|
||||
check "No key -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/rest/v1/")"
|
||||
check "Invalid key -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")"
|
||||
|
||||
echo ""
|
||||
echo "--- Auth (/auth/v1/settings) ---"
|
||||
check "Legacy ANON_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "New PUBLISHABLE_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
|
||||
fi
|
||||
|
||||
check "No key -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/auth/v1/settings")"
|
||||
|
||||
echo ""
|
||||
echo "--- Storage (/storage/v1/bucket) ---"
|
||||
# Storage has no key-auth - passes through, Storage returns its own errors
|
||||
check "No key -> not 401 (Storage handles auth)" "true" \
|
||||
"$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)"
|
||||
check "Legacy ANON_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
# With opaque key, the API gateway translates to asymmetric JWT in Authorization
|
||||
check "New PUBLISHABLE_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Storage S3 (/storage/v1/s3/) ---"
|
||||
# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression
|
||||
# passes the Authorization header through unchanged for non-sb_ values
|
||||
check "S3 route accessible" "true" \
|
||||
"$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)"
|
||||
|
||||
echo ""
|
||||
echo "--- GraphQL (/graphql/v1) ---"
|
||||
check "Legacy ANON_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/graphql/v1" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "New PUBLISHABLE_KEY" "200" \
|
||||
"$(http_status "$BASE_URL/graphql/v1" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')"
|
||||
fi
|
||||
|
||||
check "No key -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/graphql/v1" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')"
|
||||
|
||||
echo ""
|
||||
echo "--- Realtime REST (/realtime/v1/api/) ---"
|
||||
# Realtime REST API - use /api/ping to verify key auth (expect 200 with a valid key)
|
||||
check "Legacy ANON_KEY -> 200" "200" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "New PUBLISHABLE_KEY -> 200" "200" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
|
||||
fi
|
||||
|
||||
check "No key -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping")"
|
||||
|
||||
# Management endpoints must be blocked at the gateway (even with a valid key)
|
||||
check "/api/tenants blocked -> 403" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")"
|
||||
check "/api/openapi blocked -> 403" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")"
|
||||
|
||||
echo ""
|
||||
echo "--- supabase-js style requests (apikey + Authorization) ---"
|
||||
# supabase-js sends both apikey header AND Authorization: Bearer <apikey>
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "apikey + Authorization: Bearer sb_ (replace path)" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
|
||||
|
||||
check "secret apikey + Authorization: Bearer sb_secret" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_SECRET_KEY" \
|
||||
-H "Authorization: Bearer $SUPABASE_SECRET_KEY")"
|
||||
fi
|
||||
|
||||
check "Legacy apikey + Authorization: Bearer <legacy jwt>" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $ANON_KEY")"
|
||||
|
||||
check "Service role apikey + Authorization: Bearer <legacy jwt>" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY")"
|
||||
|
||||
echo ""
|
||||
echo "--- Edge cases ---"
|
||||
# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
check "sb_ in Authorization only (no apikey) -> 401" "401" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- JWKS endpoint ---"
|
||||
check "JWKS public endpoint (no auth)" "200" \
|
||||
"$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")"
|
||||
|
||||
# Verify JWKS content: should have EC key, should NOT have symmetric key
|
||||
jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json")
|
||||
jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null)
|
||||
jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null)
|
||||
check "JWKS contains EC public key" "true" "$jwks_has_ec"
|
||||
check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct"
|
||||
|
||||
#echo ""
|
||||
#echo "--- OAuth metadata endpoint ---"
|
||||
#check "well-known oauth (no auth)" "200" \
|
||||
# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")"
|
||||
|
||||
echo ""
|
||||
echo "--- Realtime WebSocket upgrade ---"
|
||||
# Test that WebSocket upgrade request gets through (expect 101 or non-401)
|
||||
# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open)
|
||||
ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
|
||||
"$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
|
||||
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
|
||||
# 101 = upgrade success, 000 = timeout (connection stayed open = success)
|
||||
check "WebSocket upgrade with legacy key -> not 401" "true" \
|
||||
"$([ "$ws_status" != "401" ] && echo true || echo false)"
|
||||
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
|
||||
"$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
|
||||
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
|
||||
check "WebSocket upgrade with opaque key -> not 401" "true" \
|
||||
"$([ "$ws_status_new" != "401" ] && echo true || echo false)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 2. User session JWT tests
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- User session JWT ---"
|
||||
|
||||
# Create user via admin API (works regardless of email autoconfirm setting)
|
||||
test_email="test-keys-$$@example.com"
|
||||
test_password="test-password-123456"
|
||||
|
||||
create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
|
||||
|
||||
test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
|
||||
|
||||
# Sign in to get session JWT
|
||||
auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
|
||||
|
||||
access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$access_token" ]; then
|
||||
# Check the algorithm in the JWT header
|
||||
jwt_alg=$(echo "$access_token" | cut -d. -f1 | \
|
||||
jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$jwt_alg" ]; then
|
||||
echo " INFO: User session JWT signed with: $jwt_alg"
|
||||
if [ "$jwt_alg" = "ES256" ]; then
|
||||
check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg"
|
||||
else
|
||||
check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Use the session JWT with PostgREST
|
||||
check "Session JWT with PostgREST" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
|
||||
check "Session JWT with PostgREST + service role key" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
|
||||
# Use the session JWT with Storage
|
||||
check "Session JWT with Storage" "200" \
|
||||
"$(http_status "$BASE_URL/storage/v1/bucket" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
|
||||
# CRITICAL: Authenticated user + opaque key (most common supabase-js flow)
|
||||
# supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer <user_session_jwt>
|
||||
# The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT
|
||||
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
echo ""
|
||||
echo "--- Authenticated user + opaque key (critical path) ---"
|
||||
check "Opaque apikey + user JWT -> PostgREST uses user JWT" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
check "Secret apikey + user JWT -> PostgREST allowed" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SUPABASE_SECRET_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \
|
||||
"$(http_status "$BASE_URL/storage/v1/bucket" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \
|
||||
"$(http_status "$BASE_URL/auth/v1/user" \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
fi
|
||||
else
|
||||
check "Sign in test user" "true" "false"
|
||||
fi
|
||||
|
||||
# Clean up test user
|
||||
if [ -n "$test_user_id" ]; then
|
||||
curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 3. HS256 backward compatibility
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- HS256 backward compatibility ---"
|
||||
|
||||
# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token)
|
||||
hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e "
|
||||
const crypto = require('crypto');
|
||||
const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
role:'anon',iss:'supabase',
|
||||
iat:Math.floor(Date.now()/1000),
|
||||
exp:Math.floor(Date.now()/1000)+3600
|
||||
})).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256',process.env.JWT_SECRET)
|
||||
.update(header+'.'+payload).digest('base64url');
|
||||
console.log(header+'.'+payload+'.'+sig);
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -n "$hs256_token" ]; then
|
||||
check "HS256 token with PostgREST (backward compat)" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $hs256_token")"
|
||||
check "HS256 token with PostgREST + service role key" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $hs256_token")"
|
||||
else
|
||||
echo " SKIP: Could not mint HS256 token (node required)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 4. JWT_KEYS format validation
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- JWT_KEYS format ---"
|
||||
|
||||
JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-)
|
||||
if [ -n "$JWT_KEYS_VAL" ]; then
|
||||
# Auth expects a JSON array, not a JWKS object
|
||||
jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null)
|
||||
check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array"
|
||||
|
||||
jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null)
|
||||
check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign"
|
||||
else
|
||||
echo " SKIP: JWT_KEYS not configured"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Verify all self-hosted Supabase services started correctly by checking log output.
|
||||
#
|
||||
# Usage:
|
||||
# sh test-container-logs.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Running self-hosted Supabase instance (docker compose up)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
project_name="${COMPOSE_PROJECT_NAME:-supabase}"
|
||||
|
||||
fail_msg() {
|
||||
fail=$((fail + 1))
|
||||
echo " FAIL: $1"
|
||||
}
|
||||
|
||||
pass_msg() {
|
||||
pass=$((pass + 1))
|
||||
echo " PASS: $1"
|
||||
}
|
||||
|
||||
# The `docker ps` fallback in the helpers below exists because the script
|
||||
# doesn't know which compose `-f` flags the user ran `up` with. `docker compose
|
||||
# ps` only sees services defined in the currently loaded compose files, but
|
||||
# compose stamps `com.docker.compose.{project,service}` labels at `up` time -
|
||||
# so a label-based lookup finds the container regardless of which override
|
||||
# files are active in this shell.
|
||||
|
||||
is_service_running() {
|
||||
service="$1"
|
||||
if docker compose ps --services --status running 2>/dev/null | grep -q "^$service$"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker ps --filter "label=com.docker.compose.project=$project_name" \
|
||||
--filter "label=com.docker.compose.service=$service" \
|
||||
--filter "status=running" \
|
||||
--quiet | grep -q '.'
|
||||
}
|
||||
|
||||
get_container_id() {
|
||||
service="$1"
|
||||
|
||||
container_id=$(docker compose ps -q "$service" 2>/dev/null || true)
|
||||
if [ -n "$container_id" ]; then
|
||||
printf '%s' "$container_id"
|
||||
return
|
||||
fi
|
||||
|
||||
container_id=$(docker ps -a \
|
||||
--filter "label=com.docker.compose.project=$project_name" \
|
||||
--filter "label=com.docker.compose.service=$service" \
|
||||
--quiet)
|
||||
|
||||
set -- $container_id
|
||||
printf '%s' "$1"
|
||||
}
|
||||
|
||||
# Check that a service's logs contain all expected patterns.
|
||||
# Logs are written to a temp file so that grep -q exits cleanly.
|
||||
check_logs() {
|
||||
service="$1"
|
||||
shift
|
||||
|
||||
logfile=$(mktemp)
|
||||
|
||||
docker compose logs "$service" > "$logfile" 2>/dev/null || true
|
||||
if [ ! -s "$logfile" ]; then
|
||||
container_id=$(get_container_id "$service")
|
||||
if [ -n "$container_id" ]; then
|
||||
docker logs "$container_id" > "$logfile" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -s "$logfile" ]; then
|
||||
rm -f "$logfile"
|
||||
fail_msg "$service (no logs found)"
|
||||
return
|
||||
fi
|
||||
|
||||
for pattern in "$@"; do
|
||||
if ! grep -q -i -E "$pattern" "$logfile"; then
|
||||
rm -f "$logfile"
|
||||
fail_msg "$service (missing: $pattern)"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$logfile"
|
||||
pass_msg "$service"
|
||||
}
|
||||
|
||||
check_logs_if_running() {
|
||||
service="$1"
|
||||
shift
|
||||
|
||||
if is_service_running "$service"; then
|
||||
check_logs "$service" "$@"
|
||||
else
|
||||
pass_msg "$service (skipped: service not running)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Checking service startup logs ==="
|
||||
echo ""
|
||||
|
||||
check_logs db \
|
||||
'PostgreSQL init process complete; ready for start up.|Skipping initialization'
|
||||
|
||||
check_logs auth \
|
||||
'db worker started'
|
||||
|
||||
# API gateway: Envoy by default, or Kong when the kong override is enabled.
|
||||
# The service is named api-gw in both cases, so accept either startup marker.
|
||||
check_logs api-gw \
|
||||
'init\.lua.*declarative config loaded|Envoy configuration generated successfully'
|
||||
|
||||
check_logs rest \
|
||||
'Schema cache loaded in.*milliseconds'
|
||||
|
||||
check_logs realtime \
|
||||
'Starting Realtime' \
|
||||
'Connected to Postgres database' \
|
||||
'Janitor started' \
|
||||
'Starting MetricsCleaner'
|
||||
|
||||
check_logs storage \
|
||||
'Started Successfully'
|
||||
|
||||
check_logs studio \
|
||||
'ready in.*s$'
|
||||
|
||||
check_logs meta \
|
||||
'Server listening at http'
|
||||
|
||||
check_logs functions \
|
||||
'main function started'
|
||||
|
||||
check_logs_if_running analytics \
|
||||
'Access LogflareWeb.Endpoint at http://localhost:4000' \
|
||||
'Executing startup tasks' \
|
||||
'Ensuring single tenant user is seeded'
|
||||
|
||||
# Database pooler: Supavisor by default, or PgBouncer when the pgbouncer
|
||||
# override is enabled (which disables Supavisor). Check whichever is running.
|
||||
if is_service_running supavisor; then
|
||||
check_logs supavisor \
|
||||
'Connected to Postgres database' \
|
||||
'HEAD /api/health$'
|
||||
elif is_service_running pgbouncer; then
|
||||
check_logs pgbouncer \
|
||||
'process up: PgBouncer' \
|
||||
'listening on .*6432'
|
||||
else
|
||||
fail_msg "pooler (neither supavisor nor pgbouncer is running)"
|
||||
fi
|
||||
|
||||
check_logs_if_running vector \
|
||||
'Vector has started'
|
||||
|
||||
check_logs imgproxy \
|
||||
'Starting server at :5001'
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
echo "Inspect logs: docker compose logs <service>"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,355 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Test Postgres 15 -> 17 upgrade for self-hosted Supabase.
|
||||
#
|
||||
# Seeds test data on a running Postgres 15 stack, runs the upgrade script,
|
||||
# and verifies data integrity + service connectivity using pgTAP.
|
||||
#
|
||||
# Usage:
|
||||
# cd docker/
|
||||
# sudo bash tests/test-pg17-upgrade.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Running self-hosted Supabase with a clean, tests-only Postgres 15.
|
||||
# Postgres 17 is now the default, so start PG 15 explicitly via the override:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
|
||||
# - .env file with POSTGRES_PASSWORD, ANON_KEY
|
||||
#
|
||||
|
||||
set -eu
|
||||
|
||||
DB_CONTAINER="supabase-db"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run from the docker/ directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-)
|
||||
anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true)
|
||||
|
||||
if [ -z "$pg_password" ]; then
|
||||
echo "Error: POSTGRES_PASSWORD not set in .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_sql() {
|
||||
docker exec -i \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
"$DB_CONTAINER" \
|
||||
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Postgres 15 -> 17 Upgrade Test ==="
|
||||
echo ""
|
||||
|
||||
# --- Verify we're starting from Postgres 15 --------------------------------
|
||||
|
||||
current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1)
|
||||
case "$current_version" in
|
||||
15.*) echo "Starting version: PostgreSQL $current_version" ;;
|
||||
17.*) echo "Error: Already on Postgres 17. Start with a PG 15 stack."; exit 1 ;;
|
||||
*) echo "Error: Unexpected version: $current_version"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# --- Seed test data --------------------------------------------------------
|
||||
# Note: this script is designed to run against a fresh docker-compose stack,
|
||||
# not an existing database with user data.
|
||||
|
||||
echo ""
|
||||
echo "Seeding test data on Postgres 15..."
|
||||
|
||||
run_sql <<'EOSQL'
|
||||
-- Test table with various column types
|
||||
CREATE TABLE IF NOT EXISTS public._upgrade_test (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
value numeric(10,2),
|
||||
created_at timestamptz DEFAULT now(),
|
||||
metadata jsonb
|
||||
);
|
||||
|
||||
TRUNCATE public._upgrade_test;
|
||||
INSERT INTO public._upgrade_test (name, value, metadata) VALUES
|
||||
('alpha', 1.50, '{"tag": "a"}'),
|
||||
('bravo', 2.75, '{"tag": "b"}'),
|
||||
('charlie', 3.00, '{"tag": "c"}'),
|
||||
('delta', 4.25, '{"tag": "d"}'),
|
||||
('echo', 5.99, '{"tag": "e"}');
|
||||
|
||||
-- Index
|
||||
CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name);
|
||||
|
||||
-- Function
|
||||
CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int)
|
||||
RETURNS int LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT n * 2;
|
||||
$$;
|
||||
|
||||
-- Grant access so PostgREST can read it
|
||||
GRANT SELECT ON public._upgrade_test TO anon, authenticated;
|
||||
|
||||
-- pg_cron: created here as supabase_admin (the common manually-enabled case),
|
||||
-- so the extension is owned by supabase_admin, NOT postgres. complete.sh's
|
||||
-- drop+recreate only fires for postgres-owned pg_cron, so this exercises the
|
||||
-- version reconcile in the upgrade script: Supabase PG 15 registers pg_cron
|
||||
-- as '1.6', while the target image packages it as '1.6.4' with no update
|
||||
-- path between them.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
|
||||
PERFORM cron.unschedule('upgrade_test_job');
|
||||
END IF;
|
||||
END $$;
|
||||
SELECT cron.schedule('upgrade_test_job', '5 4 * * *', 'SELECT 1');
|
||||
EOSQL
|
||||
|
||||
pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]')
|
||||
pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]')
|
||||
|
||||
echo " Rows: $pre_count"
|
||||
echo " Checksum: $pre_checksum"
|
||||
|
||||
# --- Seed a Vault secret ---------------------------------------------------
|
||||
# Verifies the pgsodium root key survives the volume swap/chown AND that Vault
|
||||
# secrets still decrypt on Postgres 17. For legacy (key_id-based) secrets this
|
||||
# also exercises complete.sh's pgsodium->Vault re-encryption; on a stock
|
||||
# self-hosted stack the secret is already pgsodium-less, so this confirms the
|
||||
# round-trip and the post-upgrade invariant (key_id IS NULL).
|
||||
VAULT_SECRET_NAME="upgrade_test_secret"
|
||||
VAULT_SECRET_VALUE="upgrade-test-secret-value-42"
|
||||
vault_available="f"
|
||||
if [ "$(run_sql -A -t -c "SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'supabase_vault');" | tr -d '[:space:]')" = "t" ]; then
|
||||
echo ""
|
||||
echo "Seeding Vault secret on Postgres 15..."
|
||||
run_sql <<EOSQL
|
||||
CREATE EXTENSION IF NOT EXISTS supabase_vault;
|
||||
-- Pre-clean so a re-run after a mid-run failure (where the trailing cleanup
|
||||
-- never executed) does not abort on the unique (name) index.
|
||||
DELETE FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}';
|
||||
SELECT vault.create_secret('${VAULT_SECRET_VALUE}', '${VAULT_SECRET_NAME}', 'pg17 upgrade test');
|
||||
EOSQL
|
||||
pre_secret=$(run_sql -A -t -c "SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}';" | tr -d '\n')
|
||||
if [ "$pre_secret" = "$VAULT_SECRET_VALUE" ]; then
|
||||
echo " Seeded '${VAULT_SECRET_NAME}' (decrypts correctly on PG 15)"
|
||||
else
|
||||
echo " Warning: seeded secret did not decrypt as expected on PG 15" >&2
|
||||
fi
|
||||
vault_available="t"
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping Vault seed: supabase_vault extension not available."
|
||||
fi
|
||||
|
||||
# --- Run upgrade -----------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "Running upgrade script..."
|
||||
echo ""
|
||||
|
||||
bash utils/upgrade-pg17.sh --yes
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Verify with pgTAP ----------------------------------------------------
|
||||
|
||||
echo "Running pgTAP verification..."
|
||||
echo ""
|
||||
|
||||
# Optional Vault assertions, only when a secret was seeded above.
|
||||
vault_plan=0
|
||||
vault_tests=""
|
||||
if [ "$vault_available" = "t" ]; then
|
||||
vault_plan=3
|
||||
vault_tests=$(cat <<EOSQL
|
||||
-- Vault secret survived the upgrade and still decrypts (root key intact).
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
|
||||
'Vault secret survived upgrade'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}'),
|
||||
'${VAULT_SECRET_VALUE}',
|
||||
'Vault secret still decrypts to original plaintext after upgrade'
|
||||
);
|
||||
SELECT ok(
|
||||
(SELECT key_id IS NULL FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
|
||||
'Vault secret is in pgsodium-less format (key_id IS NULL) after upgrade'
|
||||
);
|
||||
EOSQL
|
||||
)
|
||||
fi
|
||||
total_plan=$((16 + vault_plan))
|
||||
|
||||
# Use a non-quoted heredoc so $pre_count, $pre_checksum, $total_plan and
|
||||
# $vault_tests are interpolated.
|
||||
run_sql <<EOSQL
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
SELECT plan(${total_plan});
|
||||
|
||||
-- Version
|
||||
SELECT ok(version() LIKE 'PostgreSQL 17%', 'Running Postgres 17');
|
||||
|
||||
-- Table
|
||||
SELECT has_table('public', '_upgrade_test', 'Test table survived upgrade');
|
||||
|
||||
-- Row count
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public._upgrade_test),
|
||||
${pre_count},
|
||||
'Row count preserved'
|
||||
);
|
||||
|
||||
-- Data checksum
|
||||
SELECT is(
|
||||
(SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test),
|
||||
'${pre_checksum}',
|
||||
'Data checksum matches'
|
||||
);
|
||||
|
||||
-- Index
|
||||
SELECT has_index('public', '_upgrade_test', '_upgrade_test_name_idx', 'Index survived upgrade');
|
||||
|
||||
-- Function
|
||||
SELECT has_function('public', '_upgrade_test_fn', ARRAY['integer'], 'Function survived upgrade');
|
||||
SELECT is(public._upgrade_test_fn(21), 42, 'Function returns correct result');
|
||||
|
||||
-- Core extensions
|
||||
-- Note: pgsodium may not be created as an extension in the postgres database
|
||||
-- on default self-hosted installs (it's loaded via shared_preload_libraries
|
||||
-- but the CREATE EXTENSION is conditional in the init migration).
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net'),
|
||||
'pg_net extension exists'
|
||||
);
|
||||
|
||||
-- Roles
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_etl_admin'),
|
||||
'supabase_etl_admin role exists'
|
||||
);
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_read_only_user'),
|
||||
'supabase_read_only_user role exists'
|
||||
);
|
||||
|
||||
-- postgres is not superuser
|
||||
SELECT ok(
|
||||
NOT (SELECT rolsuper FROM pg_roles WHERE rolname = 'postgres'),
|
||||
'postgres role is not superuser'
|
||||
);
|
||||
|
||||
-- pg_cron: registered as 1.6 on PG 15; the upgrade must reconcile the version
|
||||
-- label to the target's packaged 1.6.4 and preserve scheduled jobs.
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron'),
|
||||
'pg_cron extension exists'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT extversion FROM pg_extension WHERE extname = 'pg_cron'),
|
||||
'1.6.4',
|
||||
'pg_cron version label reconciled to 1.6.4'
|
||||
);
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job'),
|
||||
'pg_cron scheduled job survived the upgrade'
|
||||
);
|
||||
|
||||
-- New predefined role (initdb-only migration; the target image's supautils.conf
|
||||
-- references it, so it must exist on an upgraded instance).
|
||||
SELECT ok(
|
||||
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_privileged_role'),
|
||||
'supabase_privileged_role exists'
|
||||
);
|
||||
|
||||
-- Extension version reconcile: complete.sh ran with .063 binaries, so the
|
||||
-- catalog should be reconciled to the target image's default version.
|
||||
SELECT is(
|
||||
(SELECT extversion FROM pg_extension WHERE extname = 'pg_net'),
|
||||
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_net'),
|
||||
'pg_net version reconciled to image default'
|
||||
);
|
||||
${vault_tests}
|
||||
SELECT * FROM finish(true);
|
||||
EOSQL
|
||||
|
||||
# --- Check service connectivity --------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "Checking service connectivity..."
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
check() {
|
||||
test_name="$1"
|
||||
expected="$2"
|
||||
actual="$3"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo " PASS: $test_name"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: $test_name (expected $expected, got $actual)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# PostgREST
|
||||
if [ -n "$anon_key" ]; then
|
||||
rest_status=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "apikey: $anon_key" \
|
||||
-H "Authorization: Bearer $anon_key" \
|
||||
"http://localhost:8000/rest/v1/_upgrade_test?select=count" 2>/dev/null) || rest_status="000"
|
||||
check "PostgREST connectivity" "200" "$rest_status"
|
||||
fi
|
||||
|
||||
# Auth health (needs apikey header through the API gateway)
|
||||
if [ -n "$anon_key" ]; then
|
||||
auth_status=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "apikey: $anon_key" \
|
||||
"http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000"
|
||||
check "Auth service health" "200" "$auth_status"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Services: $pass passed, $fail failed"
|
||||
|
||||
# --- Clean up test artifacts ----------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "Cleaning up test artifacts..."
|
||||
|
||||
run_sql <<'EOSQL' || true
|
||||
DROP FUNCTION IF EXISTS public._upgrade_test_fn(int);
|
||||
DROP TABLE IF EXISTS public._upgrade_test;
|
||||
DROP EXTENSION IF EXISTS pgtap;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
|
||||
PERFORM cron.unschedule('upgrade_test_job');
|
||||
END IF;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'supabase_vault') THEN
|
||||
DELETE FROM vault.secrets WHERE name = 'upgrade_test_secret';
|
||||
END IF;
|
||||
END $$;
|
||||
EOSQL
|
||||
|
||||
# --- Summary --------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
echo "=== SOME TESTS FAILED ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Upgrade test passed ==="
|
||||
echo ""
|
||||
echo "To reclaim disk space:"
|
||||
echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz"
|
||||
echo ""
|
||||
@@ -1,371 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Test S3 backend directly, bypassing the Storage service.
|
||||
#
|
||||
# Validates that the S3-compatible backend (MinIO, RustFS, etc.) handles
|
||||
# all S3 operations that Storage relies on. Uses the aws cli so the test
|
||||
# is backend-agnostic - no vendor-specific tools required.
|
||||
#
|
||||
# Usage:
|
||||
# sh test-s3-backend.sh # Uses localhost:9100
|
||||
# sh test-s3-backend.sh <backend_url> # Custom URL
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Running self-hosted Supabase instance with S3 backend + test override:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
|
||||
# -f ./tests/docker-compose.s3.test.yml up -d
|
||||
# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET
|
||||
# - aws cli v2
|
||||
# - jq (for JSON parsing)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cleanup_files=""
|
||||
trap 'rm -f $cleanup_files' EXIT
|
||||
|
||||
BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run from the project directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in aws jq; do
|
||||
if ! command -v $cmd >/dev/null 2>&1; then
|
||||
echo "Error: $cmd not found."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Read backend credentials from .env
|
||||
BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-)
|
||||
BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-)
|
||||
GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-)
|
||||
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
|
||||
REGION="${REGION:-us-east-1}"
|
||||
|
||||
if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then
|
||||
echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
check() {
|
||||
test_name="$1"
|
||||
expected="$2"
|
||||
actual="$3"
|
||||
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo " PASS: $test_name"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: $test_name (expected $expected, got $actual)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Wrapper for aws commands against the backend directly.
|
||||
#
|
||||
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
|
||||
# spot, so the check never records a FAIL and no summary is printed. The aws
|
||||
# error is echoed to stderr so a FAIL stays explainable even where the caller
|
||||
# discards stdout.
|
||||
s3() {
|
||||
s3_out=$(AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \
|
||||
AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \
|
||||
aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1) ||
|
||||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
|
||||
printf '%s\n' "$s3_out"
|
||||
}
|
||||
|
||||
# Wrapper for jq that yields empty output instead of aborting the suite when
|
||||
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
|
||||
jq_r() {
|
||||
jq -r "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
bucket_name="backend-test-$$"
|
||||
|
||||
echo ""
|
||||
echo "=== S3 backend test against $BACKEND_URL ==="
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------
|
||||
# 1. ListBuckets (backend reachable)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo "--- Connectivity ---"
|
||||
list_output=$(s3 s3api list-buckets --output json)
|
||||
list_ok=$(echo "$list_output" | jq_r 'if .Buckets then "true" else "false" end')
|
||||
check "Backend reachable (ListBuckets)" "true" "$list_ok"
|
||||
|
||||
if [ "$list_ok" != "true" ]; then
|
||||
echo " Cannot reach backend. Is the test override running?"
|
||||
echo " Response: $list_output"
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 2. Verify GLOBAL_S3_BUCKET exists
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Storage bucket ---"
|
||||
if [ -n "$GLOBAL_S3_BUCKET" ]; then
|
||||
storage_bucket_exists=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
|
||||
check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists"
|
||||
else
|
||||
echo " SKIP: GLOBAL_S3_BUCKET not set"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 3. CreateBucket
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- CreateBucket ---"
|
||||
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null
|
||||
|
||||
# Verify create succeeded
|
||||
create_found=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
|
||||
check "CreateBucket" "true" "$create_found"
|
||||
|
||||
if [ "$create_found" != "true" ]; then
|
||||
echo " Cannot continue without a bucket. Aborting."
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify in ListBuckets (separate call)
|
||||
bucket_found=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
|
||||
check "Bucket visible in ListBuckets" "true" "$bucket_found"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 4. PutObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- PutObject ---"
|
||||
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
|
||||
echo "hello from backend test" > "$tmpfile"
|
||||
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt")
|
||||
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
|
||||
check "PutObject" "true" "$put_ok"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 5. ListObjectsV2
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- ListObjectsV2 ---"
|
||||
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
|
||||
object_found=$(echo "$list_objects" | \
|
||||
jq_r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end')
|
||||
check "Object found in ListObjectsV2" "true" "$object_found"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 6. HeadObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- HeadObject ---"
|
||||
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json)
|
||||
head_size=$(echo "$head_output" | jq_r '.ContentLength // 0')
|
||||
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
|
||||
check "HeadObject returns correct size" "$original_size" "$head_size"
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 7. GetObject + content verify
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- GetObject ---"
|
||||
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
|
||||
s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null
|
||||
downloaded_content=$(cat "$download_file")
|
||||
check "GetObject content matches" "hello from backend test" "$downloaded_content"
|
||||
rm -f "$download_file"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 8. CopyObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- CopyObject ---"
|
||||
copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt")
|
||||
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
|
||||
check "CopyObject" "true" "$copy_ok"
|
||||
|
||||
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
|
||||
s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null
|
||||
check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")"
|
||||
rm -f "$copy_download"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 9. DeleteObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- DeleteObject ---"
|
||||
s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null
|
||||
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
|
||||
copy_gone=$(echo "$list_after_delete" | \
|
||||
jq_r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end')
|
||||
check "Deleted object no longer listed" "true" "$copy_gone"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 10. Multipart upload (7MB)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Multipart upload (7MB) ---"
|
||||
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
|
||||
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
|
||||
large_size=$(wc -c < "$large_file" | tr -d ' ')
|
||||
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
|
||||
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
|
||||
check "Multipart upload (7MB)" "true" "$large_ok"
|
||||
|
||||
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
|
||||
remote_size=$(echo "$large_head" | jq_r '.ContentLength // 0')
|
||||
check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size"
|
||||
|
||||
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
|
||||
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
|
||||
download_size=$(wc -c < "$large_download" | tr -d ' ')
|
||||
check "Multipart download size matches" "$large_size" "$download_size"
|
||||
rm -f "$large_file" "$large_download"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 11. DeleteObjects (batch delete)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- DeleteObjects (batch) ---"
|
||||
batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file"
|
||||
echo "batch-a" > "$batch_file"
|
||||
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null
|
||||
echo "batch-b" > "$batch_file"
|
||||
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null
|
||||
echo "batch-c" > "$batch_file"
|
||||
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null
|
||||
rm -f "$batch_file"
|
||||
delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \
|
||||
--delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \
|
||||
--output json)
|
||||
deleted_count=$(echo "$delete_objects_output" | jq_r '.Deleted | length')
|
||||
check "DeleteObjects removed 3 objects" "3" "$deleted_count"
|
||||
|
||||
# Verify all gone
|
||||
batch_list=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json)
|
||||
batch_remaining=$(echo "$batch_list" | jq_r '[.Contents[]?] | length')
|
||||
check "Batch-deleted objects gone" "0" "$batch_remaining"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 12. Presigned URLs
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Presigned URLs ---"
|
||||
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
|
||||
echo "presigned content test" > "$presign_file"
|
||||
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
|
||||
rm -f "$presign_file"
|
||||
|
||||
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
|
||||
presign_body=$(curl -s "$presigned_url" || true)
|
||||
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 13. Conditional request (IfNoneMatch)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Conditional request (IfNoneMatch) ---"
|
||||
# --if-none-match requires aws cli v2.22+ ; skip if not supported
|
||||
if aws s3api put-object help 2>&1 | grep -q 'if-none-match'; then
|
||||
cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file"
|
||||
echo "conditional test" > "$cond_file"
|
||||
|
||||
# First put should succeed (key doesn't exist)
|
||||
first_put_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
|
||||
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
|
||||
first_put_ok=$(echo "$first_put_err" | grep -qi "error\|denied\|PreconditionFailed" && echo "false" || echo "true")
|
||||
check "IfNoneMatch put (new key) succeeds" "true" "$first_put_ok"
|
||||
|
||||
# Second put should fail with PreconditionFailed (key exists)
|
||||
cond_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
|
||||
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
|
||||
cond_rejected=$(echo "$cond_err" | grep -qi "PreconditionFailed" && echo "true" || echo "false")
|
||||
check "IfNoneMatch put (existing key) rejected" "true" "$cond_rejected"
|
||||
rm -f "$cond_file"
|
||||
else
|
||||
echo " SKIP: aws cli does not support --if-none-match (requires v2.22+)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 14. Range request (partial GetObject)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Range request ---"
|
||||
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
|
||||
echo "hello range test content" > "$range_file"
|
||||
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
|
||||
rm -f "$range_file"
|
||||
|
||||
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
|
||||
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
|
||||
--range "bytes=0-4" "$range_download" --output json >/dev/null
|
||||
range_content=$(cat "$range_download")
|
||||
check "Range request returns partial content" "hello" "$range_content"
|
||||
rm -f "$range_download"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 15. Authentication
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Authentication ---"
|
||||
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
|
||||
AWS_SECRET_ACCESS_KEY="invalid-secret" \
|
||||
aws s3api list-buckets \
|
||||
--endpoint-url "$BACKEND_URL" \
|
||||
--region "$REGION" \
|
||||
--output json 2>&1 || true)
|
||||
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
|
||||
check "Invalid credentials rejected" "true" "$bad_ok"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 16. Cleanup
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Cleanup ---"
|
||||
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
|
||||
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
|
||||
bucket_gone=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end')
|
||||
check "Test bucket deleted" "true" "$bucket_gone"
|
||||
|
||||
# ---------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,301 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Test S3 protocol endpoint for self-hosted Supabase Storage.
|
||||
#
|
||||
# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with
|
||||
# standard S3 clients - the same way end users interact with it via
|
||||
# aws cli, rclone, or other S3-compatible tools.
|
||||
#
|
||||
# Usage:
|
||||
# sh test-s3.sh # Uses http://localhost:8000
|
||||
# sh test-s3.sh <base_url> # Custom URL
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Running self-hosted Supabase instance with S3 enabled:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
|
||||
# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION
|
||||
# - aws cli v2 (for S3 operations)
|
||||
# - jq (for JSON parsing)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cleanup_files=""
|
||||
trap 'rm -f $cleanup_files' EXIT
|
||||
|
||||
BASE_URL="${1:-http://localhost:8000}"
|
||||
S3_ENDPOINT="$BASE_URL/storage/v1/s3"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run from the project directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cmd in aws jq; do
|
||||
if ! command -v $cmd >/dev/null 2>&1; then
|
||||
echo "Error: $cmd not found."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Read keys from .env
|
||||
S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-)
|
||||
S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-)
|
||||
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
|
||||
|
||||
if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then
|
||||
echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
check() {
|
||||
test_name="$1"
|
||||
expected="$2"
|
||||
actual="$3"
|
||||
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo " PASS: $test_name"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: $test_name (expected $expected, got $actual)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Wrapper for aws s3/s3api commands with correct endpoint and credentials.
|
||||
#
|
||||
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
|
||||
# spot, so the check never records a FAIL and no summary is printed. The aws
|
||||
# error is echoed to stderr so a FAIL stays explainable even where the caller
|
||||
# discards stdout.
|
||||
s3() {
|
||||
s3_out=$(AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
|
||||
AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
|
||||
aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1) ||
|
||||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
|
||||
printf '%s\n' "$s3_out"
|
||||
}
|
||||
|
||||
# Wrapper for jq that yields empty output instead of aborting the suite when
|
||||
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
|
||||
jq_r() {
|
||||
jq -r "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
bucket_name="s3-test-$$"
|
||||
|
||||
echo ""
|
||||
echo "=== S3 protocol test against $BASE_URL ==="
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------
|
||||
# 1. S3 ListBuckets
|
||||
# ---------------------------------------------
|
||||
|
||||
echo "--- S3 ListBuckets ---"
|
||||
list_output=$(s3 s3api list-buckets --output json)
|
||||
list_ok=$(echo "$list_output" | jq_r 'if .Buckets then "true" else "false" end')
|
||||
check "ListBuckets returns valid response" "true" "$list_ok"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 2. S3 CreateBucket
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 CreateBucket ---"
|
||||
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null
|
||||
|
||||
# Verify create succeeded
|
||||
create_found=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
|
||||
check "CreateBucket" "true" "$create_found"
|
||||
|
||||
if [ "$create_found" != "true" ]; then
|
||||
echo " Cannot continue without a bucket. Aborting."
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify bucket appears in ListBuckets (separate call)
|
||||
s3_bucket_found=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
|
||||
check "Bucket visible in ListBuckets" "true" "$s3_bucket_found"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 3. S3 PutObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 PutObject ---"
|
||||
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
|
||||
echo "hello from s3 upload test" > "$tmpfile"
|
||||
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt")
|
||||
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
|
||||
check "PutObject upload" "true" "$put_ok"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 4. S3 ListObjectsV2
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 ListObjectsV2 ---"
|
||||
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
|
||||
object_found=$(echo "$list_objects" | \
|
||||
jq_r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end')
|
||||
check "Object found in ListObjectsV2" "true" "$object_found"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 5. S3 HeadObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 HeadObject ---"
|
||||
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json)
|
||||
head_size=$(echo "$head_output" | jq_r '.ContentLength // 0')
|
||||
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
|
||||
check "HeadObject returns correct size" "$original_size" "$head_size"
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 6. S3 GetObject (download) + content verify
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 GetObject ---"
|
||||
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
|
||||
s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null
|
||||
downloaded_content=$(cat "$download_file")
|
||||
check "GetObject content matches" "hello from s3 upload test" "$downloaded_content"
|
||||
rm -f "$download_file"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 7. S3 CopyObject (server-side copy)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 CopyObject ---"
|
||||
copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt")
|
||||
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
|
||||
check "CopyObject" "true" "$copy_ok"
|
||||
|
||||
# Verify copied content
|
||||
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
|
||||
s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null
|
||||
check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")"
|
||||
rm -f "$copy_download"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 8. S3 DeleteObject
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 DeleteObject ---"
|
||||
s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null
|
||||
# Verify object is gone
|
||||
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
|
||||
copied_gone=$(echo "$list_after_delete" | \
|
||||
jq_r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end')
|
||||
check "Deleted object no longer listed" "true" "$copied_gone"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 9. Multipart upload (>5MB triggers multipart)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- S3 multipart upload (7MB) ---"
|
||||
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
|
||||
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
|
||||
large_size=$(wc -c < "$large_file" | tr -d ' ')
|
||||
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
|
||||
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
|
||||
check "Multipart upload (7MB)" "true" "$large_ok"
|
||||
|
||||
# Verify size via HeadObject
|
||||
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
|
||||
remote_size=$(echo "$large_head" | jq_r '.ContentLength // 0')
|
||||
check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size"
|
||||
|
||||
# Download and verify size
|
||||
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
|
||||
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
|
||||
download_size=$(wc -c < "$large_download" | tr -d ' ')
|
||||
check "Multipart download size matches" "$large_size" "$download_size"
|
||||
rm -f "$large_file" "$large_download"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 10. Range request (partial GetObject)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Range request ---"
|
||||
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
|
||||
echo "hello range test content" > "$range_file"
|
||||
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
|
||||
rm -f "$range_file"
|
||||
|
||||
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
|
||||
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
|
||||
--range "bytes=0-4" "$range_download" --output json >/dev/null
|
||||
range_content=$(cat "$range_download")
|
||||
check "Range request returns partial content" "hello" "$range_content"
|
||||
rm -f "$range_download"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 11. Presigned URLs
|
||||
# ---------------------------------------------
|
||||
# Storage supports S3 presigned URLs (query-parameter auth).
|
||||
|
||||
echo ""
|
||||
echo "--- Presigned URLs ---"
|
||||
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
|
||||
echo "presigned content test" > "$presign_file"
|
||||
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
|
||||
rm -f "$presign_file"
|
||||
|
||||
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
|
||||
presign_body=$(curl -s "$presigned_url" || true)
|
||||
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 12. Authentication
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Authentication ---"
|
||||
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
|
||||
AWS_SECRET_ACCESS_KEY="invalid-secret" \
|
||||
aws s3api list-buckets \
|
||||
--endpoint-url "$S3_ENDPOINT" \
|
||||
--region "$REGION" \
|
||||
--output json 2>&1 || true)
|
||||
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
|
||||
check "Invalid credentials rejected" "true" "$bad_ok"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 13. Cleanup
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Cleanup ---"
|
||||
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
|
||||
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
|
||||
# Verify bucket is gone
|
||||
bucket_gone=$(s3 s3api list-buckets --output json | \
|
||||
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end')
|
||||
check "Bucket deleted via S3" "true" "$bucket_gone"
|
||||
|
||||
# ---------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,543 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Smoke test for self-hosted Supabase - verifies core functionality end-to-end.
|
||||
#
|
||||
# Usage:
|
||||
# sh test-self-hosted.sh # Uses http://localhost:8000
|
||||
# sh test-self-hosted.sh <base_url> # Custom URL
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Running self-hosted Supabase instance
|
||||
# - .env file with keys configured
|
||||
# - jq (for JSON parsing)
|
||||
# - sha256sum or shasum (for file integrity checks)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cleanup_files=""
|
||||
trap 'rm -f $cleanup_files' EXIT
|
||||
|
||||
BASE_URL="${1:-http://localhost:8000}"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run from the project directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Portable file hash: prefers sha256sum (Linux), falls back to shasum (macOS)
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
file_hash() { sha256sum "$1" | awk '{print $1}'; }
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
file_hash() { shasum -a 256 "$1" | awk '{print $1}'; }
|
||||
else
|
||||
echo "Error: sha256sum or shasum not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read keys from .env
|
||||
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
|
||||
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
|
||||
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
|
||||
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
|
||||
DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-)
|
||||
DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-)
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
check() {
|
||||
test_name="$1"
|
||||
expected="$2"
|
||||
actual="$3"
|
||||
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo " PASS: $test_name"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " FAIL: $test_name (expected $expected, got $actual)"
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
http_status() {
|
||||
url="$1"
|
||||
shift
|
||||
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
|
||||
}
|
||||
|
||||
http_body() {
|
||||
url="$1"
|
||||
shift
|
||||
curl -s "$@" "$url"
|
||||
}
|
||||
|
||||
# Is a compose service running? Falls back to a label lookup so it works
|
||||
# regardless of which override files are loaded in this shell.
|
||||
service_running() {
|
||||
svc="$1"
|
||||
docker compose ps --services --status running 2>/dev/null | grep -qx "$svc" && return 0
|
||||
docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME:-supabase}" \
|
||||
--filter "label=com.docker.compose.service=$svc" \
|
||||
--filter "status=running" --quiet | grep -q '.'
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Self-hosted smoke test against $BASE_URL ==="
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------
|
||||
# 1. Container health (via docker compose)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo "--- Container health ---"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
container_status=$(docker compose ps --format json 2>/dev/null | jq -rs '
|
||||
[.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))]
|
||||
| (length | tostring) + "|" + ([.[] | .Service + ": State=" + .State + " Health=" + (.Health // "none")] | join(", "))
|
||||
' 2>/dev/null || echo "?|")
|
||||
unhealthy="${container_status%%|*}"
|
||||
container_issues="${container_status#*|}"
|
||||
if [ "$unhealthy" = "0" ]; then
|
||||
check "All containers healthy" "0" "$unhealthy"
|
||||
elif [ "$unhealthy" = "?" ]; then
|
||||
echo " SKIP: Could not check container health"
|
||||
else
|
||||
check "All containers healthy ($container_issues)" "0" "$unhealthy"
|
||||
fi
|
||||
else
|
||||
echo " SKIP: docker not available"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 2. Studio dashboard
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Studio dashboard ---"
|
||||
# Studio may redirect (307/302) after auth - follow redirects
|
||||
check "Studio accessible with basic auth" "200" \
|
||||
"$(http_status "$BASE_URL/" -L -u "$DASHBOARD_USERNAME:$DASHBOARD_PASSWORD")"
|
||||
check "Studio rejects without auth" "401" \
|
||||
"$(http_status "$BASE_URL/")"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 3. Auth: create user, sign in, get user, public signup, delete
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Auth: user lifecycle ---"
|
||||
|
||||
test_email="smoke-test-$$@example.com"
|
||||
test_password="smoke-test-password-123456"
|
||||
|
||||
# Create user via admin API (works regardless of email autoconfirm setting)
|
||||
create_resp=$(http_body "$BASE_URL/auth/v1/admin/users" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
|
||||
|
||||
user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$user_id" ]; then
|
||||
check "Create user (admin)" "true" "true"
|
||||
|
||||
# Sign in via public endpoint
|
||||
signin_resp=$(http_body "$BASE_URL/auth/v1/token?grant_type=password" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
|
||||
|
||||
access_token=$(echo "$signin_resp" | jq -r '.access_token // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$access_token" ]; then
|
||||
check "Sign in user" "true" "true"
|
||||
|
||||
# Get user profile with session JWT
|
||||
check "Get user profile" "200" \
|
||||
"$(http_status "$BASE_URL/auth/v1/user" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Authorization: Bearer $access_token")"
|
||||
else
|
||||
check "Sign in user" "true" "false"
|
||||
fi
|
||||
|
||||
# Delete user
|
||||
delete_status=$(http_status "$BASE_URL/auth/v1/admin/users/$user_id" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
|
||||
check "Delete user (admin)" "200" "$delete_status"
|
||||
else
|
||||
check "Create user (admin)" "true" "false"
|
||||
fi
|
||||
|
||||
# Public signup (optional - depends on email autoconfirm setting)
|
||||
signup_email="smoke-signup-$$@example.com"
|
||||
signup_resp=$(http_body "$BASE_URL/auth/v1/signup" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$signup_email\",\"password\":\"$test_password\"}")
|
||||
|
||||
signup_token=$(echo "$signup_resp" | jq -r '.access_token // empty' 2>/dev/null)
|
||||
signup_user_id=$(echo "$signup_resp" | jq -r '.id // .user.id // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$signup_token" ]; then
|
||||
check "Public signup (autoconfirm on)" "true" "true"
|
||||
else
|
||||
echo " SKIP: Public signup (autoconfirm is off)"
|
||||
fi
|
||||
|
||||
# Clean up signup user if created
|
||||
if [ -n "$signup_user_id" ]; then
|
||||
http_status "$BASE_URL/auth/v1/admin/users/$signup_user_id" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 4. PostgREST: query
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- PostgREST ---"
|
||||
check "REST API route with anon key" "403" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
|
||||
echo ""
|
||||
echo "--- PostgREST ---"
|
||||
check "REST API route with service role key" "200" \
|
||||
"$(http_status "$BASE_URL/rest/v1/" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY")"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 5. GraphQL
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- GraphQL (optional; off by default) ---"
|
||||
# pg_graphql is OFF by default since the PG17 image (the image drops the extension
|
||||
# on init, matching platform behavior for new projects), but users may enable it
|
||||
# (Studio extensions UI / CREATE EXTENSION pg_graphql). Both are valid states. A
|
||||
# healthy endpoint returns HTTP 200 either way:
|
||||
# enabled => {"data": ...}
|
||||
# disabled => {"errors":[{"message":"pg_graphql extension is not enabled."}]}
|
||||
# Assert the status AND the response shape, so a non-200, non-JSON, or empty body
|
||||
# (a real gateway/runtime failure) is not silently classified as "disabled".
|
||||
gql_status=$(http_status "$BASE_URL/graphql/v1" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')
|
||||
gql_body=$(http_body "$BASE_URL/graphql/v1" \
|
||||
-H "apikey: $ANON_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"{ __typename }"}')
|
||||
if [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.data' >/dev/null 2>&1; then
|
||||
gql_state="enabled"
|
||||
elif [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.errors' >/dev/null 2>&1; then
|
||||
gql_state="disabled"
|
||||
else
|
||||
gql_state="unhealthy (HTTP $gql_status)"
|
||||
fi
|
||||
case "$gql_state" in
|
||||
enabled | disabled) gql_health="healthy" ;;
|
||||
*) gql_health="unhealthy" ;;
|
||||
esac
|
||||
check "GraphQL endpoint healthy" "healthy" "$gql_health"
|
||||
echo " (GraphQL is $gql_state)"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 6. Storage: create bucket, upload >6MB file, download, cleanup
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Storage: bucket + file lifecycle ---"
|
||||
|
||||
bucket_name="smoke-test-$$"
|
||||
|
||||
# Create bucket
|
||||
create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}")
|
||||
check "Create bucket" "200" "$create_bucket_status"
|
||||
|
||||
if [ "$create_bucket_status" = "200" ]; then
|
||||
# Generate a ~7MB file
|
||||
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
|
||||
dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null
|
||||
|
||||
# Upload file
|
||||
upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$tmpfile")
|
||||
check "Upload 7MB file" "200" "$upload_status"
|
||||
|
||||
# Download file and verify integrity
|
||||
download_tmp=$(mktemp); cleanup_files="$cleanup_files $download_tmp"
|
||||
curl -s "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" -o "$download_tmp"
|
||||
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
|
||||
download_size=$(wc -c < "$download_tmp" | tr -d ' ')
|
||||
check "Download file (size matches)" "$original_size" "$download_size"
|
||||
original_hash=$(file_hash "$tmpfile")
|
||||
download_hash=$(file_hash "$download_tmp")
|
||||
check "Download file (hash matches)" "$original_hash" "$download_hash"
|
||||
rm -f "$download_tmp"
|
||||
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# Signed URL: upload a small file, create signed URL, fetch without auth
|
||||
sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary "signed url test content")
|
||||
check "Upload file for signing" "200" "$sign_upload_status"
|
||||
|
||||
if [ "$sign_upload_status" = "200" ]; then
|
||||
sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"expiresIn": 600}')
|
||||
signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null)
|
||||
|
||||
if [ -n "$signed_path" ]; then
|
||||
check "Create signed URL" "true" "true"
|
||||
# Fetch signed URL without any auth headers (goes through the API gateway)
|
||||
signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path")
|
||||
check "Fetch signed URL (no auth)" "signed url test content" "$signed_content"
|
||||
else
|
||||
check "Create signed URL" "true" "false"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Delete file
|
||||
delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
|
||||
check "Delete file" "200" "$delete_file_status"
|
||||
|
||||
# Delete signed test file
|
||||
http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
|
||||
|
||||
# Delete bucket
|
||||
delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
|
||||
check "Delete bucket" "200" "$delete_bucket_status"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 6b. Storage: TUS resumable upload
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Storage: TUS resumable upload ---"
|
||||
|
||||
tus_bucket="smoke-tus-$$"
|
||||
|
||||
tus_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
|
||||
-X POST \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$tus_bucket\",\"name\":\"$tus_bucket\",\"public\":true}")
|
||||
check "TUS: create bucket" "200" "$tus_bucket_status"
|
||||
|
||||
if [ "$tus_bucket_status" = "200" ]; then
|
||||
# Generate a ~7MB file (above Studio's 6MB TUS threshold)
|
||||
tusfile=$(mktemp); cleanup_files="$cleanup_files $tusfile"
|
||||
dd if=/dev/urandom of="$tusfile" bs=1048576 count=7 2>/dev/null
|
||||
tus_file_size=$(wc -c < "$tusfile" | tr -d ' ')
|
||||
tus_chunk_size=$((4 * 1048576)) # 4MB first chunk
|
||||
|
||||
# Encode TUS metadata values as base64
|
||||
tus_bucket_b64=$(printf '%s' "$tus_bucket" | base64)
|
||||
tus_object_b64=$(printf '%s' "tus-test-file.bin" | base64)
|
||||
tus_mime_b64=$(printf '%s' "application/octet-stream" | base64)
|
||||
|
||||
# 1. Create resumable upload
|
||||
tus_create_resp=$(curl -s -i -X POST \
|
||||
"$BASE_URL/storage/v1/upload/resumable" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Length: $tus_file_size" \
|
||||
-H "Upload-Metadata: bucketName $tus_bucket_b64,objectName $tus_object_b64,contentType $tus_mime_b64" \
|
||||
-H "x-upsert: true")
|
||||
tus_create_status=$(echo "$tus_create_resp" | grep -m1 '^HTTP/' | grep -o '[0-9][0-9][0-9]')
|
||||
# Supabase Storage always returns an absolute Location URL (see generateUrl in storage/src/http/routes/tus/lifecycle.ts)
|
||||
tus_location=$(echo "$tus_create_resp" | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
|
||||
check "TUS: create resumable upload" "201" "$tus_create_status"
|
||||
|
||||
if [ -n "$tus_location" ]; then
|
||||
# 2. Upload first chunk (0 to 4MB)
|
||||
tus_chunk1_status=$(dd if="$tusfile" bs=1048576 count=4 2>/dev/null | \
|
||||
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
|
||||
"$tus_location" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Offset: 0" \
|
||||
-H "Content-Type: application/offset+octet-stream" \
|
||||
--data-binary @-)
|
||||
check "TUS: upload chunk 1 (4MB)" "204" "$tus_chunk1_status"
|
||||
|
||||
# 3. Upload second chunk (4MB to end)
|
||||
tus_remaining=$((tus_file_size - tus_chunk_size))
|
||||
tus_chunk2_status=$(dd if="$tusfile" bs=1048576 skip=4 count=3 2>/dev/null | \
|
||||
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
|
||||
"$tus_location" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H "Tus-Resumable: 1.0.0" \
|
||||
-H "Upload-Offset: $tus_chunk_size" \
|
||||
-H "Content-Type: application/offset+octet-stream" \
|
||||
--data-binary @-)
|
||||
check "TUS: upload chunk 2 (remaining)" "204" "$tus_chunk2_status"
|
||||
|
||||
# 4. Verify download matches original (hash check proves correct chunk reassembly)
|
||||
tus_download_tmp=$(mktemp); cleanup_files="$cleanup_files $tus_download_tmp"
|
||||
curl -s "$BASE_URL/storage/v1/object/public/$tus_bucket/tus-test-file.bin" -o "$tus_download_tmp"
|
||||
tus_download_size=$(wc -c < "$tus_download_tmp" | tr -d ' ')
|
||||
check "TUS: download size matches" "$tus_file_size" "$tus_download_size"
|
||||
tus_original_hash=$(file_hash "$tusfile")
|
||||
tus_download_hash=$(file_hash "$tus_download_tmp")
|
||||
check "TUS: download hash matches" "$tus_original_hash" "$tus_download_hash"
|
||||
rm -f "$tus_download_tmp"
|
||||
fi
|
||||
|
||||
rm -f "$tusfile"
|
||||
|
||||
# Cleanup: delete file and bucket
|
||||
http_status "$BASE_URL/storage/v1/object/$tus_bucket/tus-test-file.bin" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
|
||||
|
||||
tus_delete_bucket=$(http_status "$BASE_URL/storage/v1/bucket/$tus_bucket" \
|
||||
-X DELETE \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" \
|
||||
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
|
||||
check "TUS: delete bucket" "200" "$tus_delete_bucket"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# 7. Edge Functions
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Edge Functions ---"
|
||||
fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \
|
||||
-X POST \
|
||||
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}')
|
||||
check "Call hello function" '{"message":"Hello from Edge Functions!"}' "$fn_resp"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 8. pg-meta (Studio backend)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- pg-meta ---"
|
||||
check "pg-meta with service_role key" "200" \
|
||||
"$(http_status "$BASE_URL/pg/schemas" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY")"
|
||||
check "pg-meta rejects anon key" "403" \
|
||||
"$(http_status "$BASE_URL/pg/schemas" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
check "pg-meta rejects no key" "401" \
|
||||
"$(http_status "$BASE_URL/pg/schemas")"
|
||||
|
||||
echo ""
|
||||
echo "--- MCP (blocked by default) ---"
|
||||
check "/api/mcp blocked" "403" \
|
||||
"$(http_status "$BASE_URL/api/mcp")"
|
||||
check "/mcp blocked" "403" \
|
||||
"$(http_status "$BASE_URL/mcp")"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 9. Realtime
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Realtime ---"
|
||||
check "Realtime health (ping)" "200" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/ping" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
|
||||
# Management endpoints must be blocked at the gateway (even with a valid key)
|
||||
check "Realtime /api/tenants blocked" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/tenants" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
check "Realtime /api/openapi blocked" "403" \
|
||||
"$(http_status "$BASE_URL/realtime/v1/api/openapi" \
|
||||
-H "apikey: $ANON_KEY")"
|
||||
|
||||
# ---------------------------------------------
|
||||
# 10. Database pooler (transaction mode)
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "--- Database pooler (transaction mode) ---"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)
|
||||
pooler_tenant_id=$(grep '^POOLER_TENANT_ID=' .env | cut -d= -f2-)
|
||||
# Connect as the postgres role through whichever transaction pooler is running:
|
||||
# Supavisor (default) -> service 'supavisor', port 6543, user 'postgres.<tenant>'
|
||||
# PgBouncer (override) -> service 'pgbouncer', port 6432 (published as 6543), user 'postgres'
|
||||
# psql runs inside the db container (always has a client) and reaches the
|
||||
# pooler over the compose network.
|
||||
if service_running supavisor; then
|
||||
pooler_user="postgres.$pooler_tenant_id"; pooler_host="supavisor"; pooler_port=6543
|
||||
elif service_running pgbouncer; then
|
||||
pooler_user="postgres"; pooler_host="pgbouncer"; pooler_port=6432
|
||||
else
|
||||
pooler_user=""
|
||||
fi
|
||||
if [ -n "$pooler_user" ]; then
|
||||
pooler_result=$(docker exec -e PGPASSWORD="$pg_password" supabase-db \
|
||||
psql "host=$pooler_host port=$pooler_port user=$pooler_user dbname=postgres sslmode=disable" \
|
||||
-tAc "select 'pooler_ok';" 2>/dev/null | tr -d '[:space:]')
|
||||
check "Pooler transaction-mode query (as postgres)" "pooler_ok" "$pooler_result"
|
||||
else
|
||||
check "Pooler running (supavisor or pgbouncer)" "true" "false"
|
||||
fi
|
||||
else
|
||||
echo " SKIP: docker not available"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $pass passed, $fail failed ==="
|
||||
echo ""
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,380 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Hermetic test for update.sh (the self-hosted in-place update script).
|
||||
#
|
||||
# Builds a tiny synthetic "upstream" git repo with two tagged releases
|
||||
# (self-hosted/v0.9.0 -> self-hosted/v1.1.0; 1.0.0 exists only as a manifest key),
|
||||
# where the target ships a breaking-change manifest with entries at the base and
|
||||
# inside the window. It then simulates a configured deployment based on v0.9.0
|
||||
# and runs update.sh via a SUPABASE_REPO_URL override (no network), asserting:
|
||||
# the 3-way merge preserves secrets/data/overrides, adds new .env keys (but not
|
||||
# ones the user commented out), applies clean merges, reports real conflicts,
|
||||
# honors .gitignore for user-owned paths, surfaces only the in-window gate
|
||||
# entries (base-version entry excluded), and advances the stamp ONLY on a clean
|
||||
# apply. Also covers the clean-apply path, default-target resolution, --from,
|
||||
# --dry-run, the missing-stamp report-only path, and a malformed manifest being
|
||||
# refused.
|
||||
#
|
||||
# Usage:
|
||||
# sh tests/test-update.sh # run from the docker/ directory
|
||||
#
|
||||
|
||||
set -eu
|
||||
|
||||
# Isolate from the developer's global/system git config (gpgsign, hooksPath,
|
||||
# templateDir, core.excludesfile) so neither the synthetic commits nor update.sh's
|
||||
# internal git calls (fetch, merge-file, check-ignore) are affected. Exported so
|
||||
# the update.sh subprocess inherits them too.
|
||||
export GIT_CONFIG_GLOBAL=/dev/null
|
||||
export GIT_CONFIG_NOSYSTEM=1
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
|
||||
UPDATE_SH="$DOCKER_DIR/update.sh"
|
||||
|
||||
[ -f "$UPDATE_SH" ] || { echo "ERROR: $UPDATE_SH not found"; exit 1; }
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
|
||||
|
||||
assert_file_contains() { # <file> <pattern> <desc>
|
||||
if grep -qF "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (missing '$2' in $1)"; fi
|
||||
}
|
||||
assert_file_missing_pattern() { # <file> <pattern> <desc>
|
||||
if grep -qF "$2" "$1" 2>/dev/null; then bad "$3 (unexpected '$2' in $1)"; else ok "$3"; fi
|
||||
}
|
||||
assert_line() { # <file> <ERE> <desc>
|
||||
if grep -qE "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (no line matching /$2/ in $1)"; fi
|
||||
}
|
||||
assert_no_line() { # <file> <ERE> <desc>
|
||||
if grep -qE "$2" "$1" 2>/dev/null; then bad "$3 (unexpected line matching /$2/ in $1)"; else ok "$3"; fi
|
||||
}
|
||||
assert_path_exists() { # <path> <desc>
|
||||
if [ -e "$1" ]; then ok "$2"; else bad "$2 ($1 missing)"; fi
|
||||
}
|
||||
assert_path_absent() { # <path> <desc>
|
||||
if [ -e "$1" ]; then bad "$2 ($1 should not exist)"; else ok "$2"; fi
|
||||
}
|
||||
|
||||
# portable in-place sed (BSD + GNU): sedi <expr> <file>
|
||||
sedi() { sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2"; }
|
||||
|
||||
# --- 1. Build the synthetic upstream repo -----------------------------------
|
||||
|
||||
SRC="$WORK/upstream"
|
||||
mkdir -p "$SRC/docker/volumes/api"
|
||||
cd "$SRC"
|
||||
git init -q
|
||||
git config user.email t@t.t
|
||||
git config user.name t
|
||||
|
||||
cat > docker/docker-compose.yml <<'EOF'
|
||||
services:
|
||||
studio:
|
||||
image: supabase/studio:OLD
|
||||
db:
|
||||
image: supabase/postgres:15
|
||||
EOF
|
||||
cat > docker/.env.example <<'EOF'
|
||||
POSTGRES_PASSWORD=changeme
|
||||
JWT_SECRET=changeme
|
||||
KEEP_ME=base-default
|
||||
EOF
|
||||
printf 'base kong\n' > docker/volumes/api/kong.yml
|
||||
printf 'remove me\n' > docker/old-only.txt
|
||||
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
|
||||
mkdir -p docker/volumes/functions/main
|
||||
printf 'base main\n' > docker/volumes/functions/main/index.ts
|
||||
git add -A && git commit -qm base && git tag self-hosted/v0.9.0
|
||||
|
||||
# target commit
|
||||
cat > docker/docker-compose.yml <<'EOF'
|
||||
services:
|
||||
studio:
|
||||
image: supabase/studio:NEW
|
||||
db:
|
||||
image: supabase/postgres:17
|
||||
EOF
|
||||
cat > docker/.env.example <<'EOF'
|
||||
POSTGRES_PASSWORD=changeme
|
||||
JWT_SECRET=changeme
|
||||
KEEP_ME=base-default
|
||||
NEW_KEY=new-default
|
||||
EOF
|
||||
printf 'brand new\n' > docker/new-only.txt
|
||||
printf 'target main\n' > docker/volumes/functions/main/index.ts
|
||||
mkdir -p docker/volumes/functions/hello
|
||||
printf 'target hello\n' > docker/volumes/functions/hello/index.ts
|
||||
# A gitignored sample under volumes/snippets shipped by upstream: must NOT
|
||||
# overwrite the user's file of the same name (exercises is_excluded directly).
|
||||
mkdir -p docker/volumes/snippets
|
||||
printf 'VENDOR SEED\n' > docker/volumes/snippets/seed.sql
|
||||
# Manifest with entries at/below and inside the window:
|
||||
# 0.9.0 == the base -> must be EXCLUDED (window is half-open: (base, target]).
|
||||
# 1.0.0 inside -> surfaces (carries requires+gate).
|
||||
# 1.1.0 == target -> surfaces; requires-less and sorts LAST (no _schema after),
|
||||
# guarding the set -e regression where a requires-less final entry
|
||||
# aborted the script. Do NOT add a version key after 1.1.0.
|
||||
cat > docker/upgrades.json <<'EOF'
|
||||
{
|
||||
"0.9.0": {
|
||||
"breaking": true
|
||||
},
|
||||
"1.0.0": {
|
||||
"breaking": true,
|
||||
"gate": "utils/demo-migrate.sh",
|
||||
"migration_guide_url": "https://example.test/guide",
|
||||
"requires": ["Run the demo migration first."]
|
||||
},
|
||||
"1.1.0": {
|
||||
"breaking": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
git rm -q docker/old-only.txt
|
||||
git add -A
|
||||
git add -f docker/volumes/functions/hello/index.ts docker/volumes/snippets/seed.sql
|
||||
# Release tag for the target / default-target (latest self-hosted/v*) path.
|
||||
git commit -qm target && git tag self-hosted/v1.1.0
|
||||
|
||||
# A ref whose upgrades.json is valid JSON but NOT an object. update.sh must
|
||||
# refuse (die) rather than silently skip the gate. Named so latest_release_tag
|
||||
# ignores it (not self-hosted/v*), leaving the default-target path on v1.1.0.
|
||||
printf '["valid JSON, but not an object"]\n' > docker/upgrades.json
|
||||
git add -A && git commit -qm 'malformed manifest' && git tag malformed-manifest
|
||||
|
||||
# --- helper: lay down a deployment based on v0.9.0 --------------------------
|
||||
# make_deploy <dir> [conflict] - pass "conflict" to pin the studio image so the
|
||||
# merge produces a real conflict; omit for a clean apply.
|
||||
|
||||
make_deploy() { # <dir> [conflict]
|
||||
d="$1"
|
||||
_mode="${2:-clean}"
|
||||
mkdir -p "$d"
|
||||
git -C "$SRC" archive self-hosted/v0.9.0 docker | tar -x -C "$d" --strip-components=1
|
||||
cp "$UPDATE_SH" "$d/update.sh"
|
||||
# configured .env: real secret, an extra user key, and KEEP_ME commented out
|
||||
# on purpose (must NOT be re-added by the .env key-union).
|
||||
cp "$d/.env.example" "$d/.env"
|
||||
sedi "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=test-secret-123/" "$d/.env"
|
||||
sedi "s/^KEEP_ME=/#KEEP_ME=/" "$d/.env"
|
||||
printf 'EXTRA_USER_KEY=mine\n' >> "$d/.env"
|
||||
# user-owned override (must never be touched)
|
||||
printf 'services: {}\n# my override\n' > "$d/docker-compose.override.yml"
|
||||
# data dirs with sentinels (must never be touched)
|
||||
mkdir -p "$d/volumes/db/data" "$d/volumes/storage"
|
||||
printf 'DBDATA\n' > "$d/volumes/db/data/keep.txt"
|
||||
printf 'OBJ\n' > "$d/volumes/storage/keep.txt"
|
||||
# user adds a line to kong (upstream unchanged -> clean merge, must survive)
|
||||
printf 'user added line\n' >> "$d/volumes/api/kong.yml"
|
||||
# user-owned paths per .gitignore (must not be touched by the merge)
|
||||
mkdir -p "$d/volumes/snippets" "$d/volumes/functions/my-fn"
|
||||
printf 'USER_SNIPPET\n' > "$d/volumes/snippets/user.sql"
|
||||
printf 'user fn\n' > "$d/volumes/functions/my-fn/index.ts"
|
||||
# user file at the SAME path as the vendor snippet shipped at target:
|
||||
# is_excluded must skip it so the user's content survives.
|
||||
printf 'USER SEED\n' > "$d/volumes/snippets/seed.sql"
|
||||
# legacy sample fn in snapshot at target but gitignored - must not overwrite
|
||||
mkdir -p "$d/volumes/functions/hello"
|
||||
printf 'user hello\n' > "$d/volumes/functions/hello/index.ts"
|
||||
# version stamp pointing at the base (ref only; update.sh derives the rest)
|
||||
printf 'ref=self-hosted/v0.9.0\n' > "$d/.supabase-version"
|
||||
if [ "$_mode" = "conflict" ]; then
|
||||
# user pins the studio image (same line upstream changes -> conflict)
|
||||
sedi "s#supabase/studio:OLD#supabase/studio:USER-PINNED#" "$d/docker-compose.yml"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: apply path (with a conflict) ==="
|
||||
|
||||
DEPLOY="$WORK/deploy"
|
||||
make_deploy "$DEPLOY" conflict
|
||||
cd "$DEPLOY"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/apply.log" 2>&1 || rc=$?
|
||||
|
||||
sed 's/^/ | /' "$WORK/apply.log"
|
||||
|
||||
if [ "$rc" = "2" ]; then ok "exit status 2 signals conflicts"; else bad "expected exit 2 (conflicts), got $rc"; fi
|
||||
assert_file_contains ".env" "POSTGRES_PASSWORD=test-secret-123" "user secret preserved"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended"
|
||||
assert_file_contains ".env" "EXTRA_USER_KEY=mine" "extra user key kept"
|
||||
assert_line ".env" "^#KEEP_ME=base-default" "user's commented key left commented"
|
||||
assert_no_line ".env" "^KEEP_ME=" "commented .env key not re-added uncommented"
|
||||
assert_file_contains "docker-compose.override.yml" "my override" "override untouched"
|
||||
assert_file_contains "volumes/db/data/keep.txt" "DBDATA" "db data untouched"
|
||||
assert_file_contains "volumes/storage/keep.txt" "OBJ" "storage untouched"
|
||||
assert_file_contains "docker-compose.yml" "<<<<<<<" "conflict open marker written"
|
||||
assert_file_contains "docker-compose.yml" "=======" "conflict separator written"
|
||||
assert_file_contains "docker-compose.yml" ">>>>>>>" "conflict close marker written"
|
||||
assert_file_contains "docker-compose.yml" "USER-PINNED" "user value present in conflict"
|
||||
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "upstream value present in conflict"
|
||||
assert_file_contains "docker-compose.yml" "supabase/postgres:17" "non-conflicting line merged (pg17)"
|
||||
assert_path_exists "new-only.txt" "new upstream file added"
|
||||
assert_path_exists "old-only.txt" "removed-upstream file left in place"
|
||||
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "stamp NOT advanced on conflict"
|
||||
assert_file_contains "$WORK/apply.log" "Files with merge conflicts" "conflicts reported in summary"
|
||||
assert_file_contains "$WORK/apply.log" "[1.0.0]" "manifest entry 1.0.0 surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "[1.1.0] BREAKING" "requires-less last entry surfaced (no set -e abort)"
|
||||
assert_file_missing_pattern "$WORK/apply.log" "[0.9.0]" "base-version entry excluded (window lower bound is half-open)"
|
||||
assert_file_contains "$WORK/apply.log" "Run the demo migration first." "manifest gate step surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "utils/demo-migrate.sh" "manifest gate script surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "example.test/guide" "manifest migration guide surfaced"
|
||||
assert_file_contains "$WORK/apply.log" "gone from the new .env.example" ".env key-removal section shown"
|
||||
assert_file_contains "$WORK/apply.log" "EXTRA_USER_KEY" "removed .env key listed in report"
|
||||
if ls backups/*.tgz >/dev/null 2>&1; then
|
||||
ok "backup archive created"
|
||||
for _bk in backups/*.tgz; do break; done
|
||||
tar tzf "$_bk" > "$WORK/bk.list" 2>/dev/null || true
|
||||
assert_file_contains "$WORK/bk.list" ".env" "backup includes .env"
|
||||
assert_file_missing_pattern "$WORK/bk.list" "volumes/db/data" "backup excludes db data dir"
|
||||
assert_file_missing_pattern "$WORK/bk.list" "volumes/storage" "backup excludes storage dir"
|
||||
assert_file_missing_pattern "$WORK/bk.list" "backups/" "backup excludes backups dir"
|
||||
else
|
||||
bad "no backup archive"
|
||||
fi
|
||||
assert_file_contains "volumes/snippets/user.sql" "USER_SNIPPET" "snippets left untouched"
|
||||
# seed.sql and hello/index.ts are the only files that are BOTH shipped in the
|
||||
# target snapshot AND gitignored, so they are the real is_excluded coverage.
|
||||
# Assert the user's content survives AND no vendor content / conflict markers
|
||||
# leaked in - i.e. the file was skipped, not merged/conflicted.
|
||||
assert_file_contains "volumes/snippets/seed.sql" "USER SEED" "gitignored snippet: user content kept"
|
||||
assert_file_missing_pattern "volumes/snippets/seed.sql" "VENDOR SEED" "gitignored snippet: no vendor content"
|
||||
assert_file_missing_pattern "volumes/snippets/seed.sql" "<<<<<<<" "gitignored snippet: not conflicted (skipped)"
|
||||
assert_file_contains "volumes/functions/my-fn/index.ts" "user fn" "custom edge fn left untouched"
|
||||
assert_file_contains "volumes/functions/main/index.ts" "target main" "vendor main/index.ts updated"
|
||||
assert_file_contains "volumes/functions/hello/index.ts" "user hello" "gitignored fn: user content kept"
|
||||
assert_file_missing_pattern "volumes/functions/hello/index.ts" "target hello" "gitignored fn: no vendor content"
|
||||
assert_file_missing_pattern "volumes/functions/hello/index.ts" "<<<<<<<" "gitignored fn: not conflicted (skipped)"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: clean apply (no conflict) advances the stamp and exits 0 ==="
|
||||
|
||||
CLEAN="$WORK/clean"
|
||||
make_deploy "$CLEAN"
|
||||
cd "$CLEAN"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/clean.log" 2>&1 || rc=$?
|
||||
if [ "$rc" = "0" ]; then ok "clean apply exits 0"; else bad "clean apply expected exit 0, got $rc"; fi
|
||||
assert_file_contains "$WORK/clean.log" "Update applied cleanly." "clean apply announced"
|
||||
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "clean apply wrote no conflict markers"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced on clean apply"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended (clean)"
|
||||
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "vendor file updated (clean)"
|
||||
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line (clean)"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: default target resolves to latest self-hosted/v* tag ==="
|
||||
|
||||
TAGD="$WORK/tagdefault"
|
||||
make_deploy "$TAGD"
|
||||
cd "$TAGD"
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --yes > "$WORK/tag.log" 2>&1 || true
|
||||
assert_file_contains "$WORK/tag.log" "Latest release tag: self-hosted/v1.1.0" "resolved latest release tag (no --to)"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced to the tag"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "update applied via default target"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: --from supplies the base when the stamp is missing ==="
|
||||
|
||||
FROMD="$WORK/fromd"
|
||||
make_deploy "$FROMD"
|
||||
cd "$FROMD"
|
||||
rm -f .supabase-version
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --from self-hosted/v0.9.0 --to self-hosted/v1.1.0 --yes > "$WORK/from.log" 2>&1 || rc=$?
|
||||
assert_file_missing_pattern "$WORK/from.log" "REPORT-ONLY" "--from performs a real update (not report-only)"
|
||||
assert_file_contains ".env" "NEW_KEY=new-default" "--from applied the update"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "--from advanced the stamp"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: --dry-run writes nothing ==="
|
||||
|
||||
DRYD="$WORK/dry"
|
||||
make_deploy "$DRYD" conflict
|
||||
cd "$DRYD"
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --dry-run > "$WORK/dry.log" 2>&1 || true
|
||||
assert_file_missing_pattern ".env" "NEW_KEY" "dry-run did not append env key"
|
||||
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "dry-run did not write conflict"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "dry-run left stamp unchanged"
|
||||
assert_path_absent "backups" "dry-run took no backup"
|
||||
assert_file_contains "$WORK/dry.log" "DRY RUN" "dry-run labeled output"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: missing stamp -> report-only (still surfaces the gate) ==="
|
||||
|
||||
MISS="$WORK/miss"
|
||||
make_deploy "$MISS"
|
||||
cd "$MISS"
|
||||
rm -f .supabase-version
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 > "$WORK/miss.log" 2>&1 || rc=$?
|
||||
if [ "$rc" = "0" ]; then ok "report-only exits 0"; else bad "report-only expected exit 0, got $rc"; fi
|
||||
assert_file_contains "$WORK/miss.log" "REPORT-ONLY" "report-only mode announced"
|
||||
assert_file_missing_pattern ".env" "NEW_KEY" "report-only wrote nothing to .env"
|
||||
assert_path_absent "backups" "report-only took no backup"
|
||||
assert_file_contains "$WORK/miss.log" "Breaking changes / required manual steps" "report-only surfaces the gate"
|
||||
assert_file_contains "$WORK/miss.log" "[1.0.0]" "report-only lists in-range breaking release"
|
||||
assert_file_contains "$WORK/miss.log" "[0.9.0]" "report-only (open lower bound) includes the base-version entry"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: malformed manifest -> refuses (dies), writes nothing ==="
|
||||
|
||||
BADM="$WORK/badmanifest"
|
||||
make_deploy "$BADM"
|
||||
cd "$BADM"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to malformed-manifest --yes > "$WORK/bad.log" 2>&1 || rc=$?
|
||||
if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then ok "malformed manifest aborts (die, not a normal exit)"; else bad "expected die (non-0, non-2), got $rc"; fi
|
||||
assert_file_contains "$WORK/bad.log" "not a valid JSON object" "malformed-manifest error surfaced"
|
||||
assert_file_missing_pattern ".env" "NEW_KEY" "malformed manifest: .env untouched"
|
||||
assert_path_absent "backups" "malformed manifest: no backup taken"
|
||||
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "malformed manifest: stamp not advanced"
|
||||
|
||||
echo ""
|
||||
echo "=== update.sh: never overwrites the running script; stages the target's copy as .dist ==="
|
||||
|
||||
# A dedicated upstream whose docker/ ships an update.sh that DIFFERS from the one
|
||||
# we run, so the merge must divert it to update.sh.dist rather than rewrite the
|
||||
# live script mid-run (which would corrupt this process).
|
||||
SELFSRC="$WORK/selfsrc"
|
||||
mkdir -p "$SELFSRC/docker"
|
||||
cd "$SELFSRC"
|
||||
git init -q
|
||||
git config user.email t@t.t
|
||||
git config user.name t
|
||||
printf 'services:\n db:\n image: x\n' > docker/docker-compose.yml
|
||||
printf 'KEEP=1\n' > docker/.env.example
|
||||
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
|
||||
printf '#!/bin/sh\necho THIS-IS-THE-NEW-UPDATE-SH\n' > docker/update.sh
|
||||
git add -A && git commit -qm self && git tag self-hosted/v2.0.0
|
||||
|
||||
SELFDEP="$WORK/selfdep"
|
||||
mkdir -p "$SELFDEP"
|
||||
printf 'services:\n db:\n image: x\n' > "$SELFDEP/docker-compose.yml"
|
||||
printf 'KEEP=1\n' > "$SELFDEP/.env"
|
||||
cp "$UPDATE_SH" "$SELFDEP/update.sh" # the running script = the real update.sh
|
||||
printf 'ref=self-hosted/v2.0.0\n' > "$SELFDEP/.supabase-version"
|
||||
cd "$SELFDEP"
|
||||
rc=0
|
||||
SUPABASE_REPO_URL="$SELFSRC" sh ./update.sh --to self-hosted/v2.0.0 --yes > "$WORK/self.log" 2>&1 || rc=$?
|
||||
if [ "$rc" = "0" ]; then ok "self-update run exits 0"; else bad "expected exit 0, got $rc"; fi
|
||||
if cmp -s ./update.sh "$UPDATE_SH"; then ok "running update.sh left byte-identical"; else bad "running update.sh was modified in place"; fi
|
||||
assert_no_line "./update.sh" '^(<<<<<<<|=======|>>>>>>>)' "no conflict markers written into the running update.sh"
|
||||
assert_path_exists "$SELFDEP/update.sh.dist" "target's update.sh staged as update.sh.dist"
|
||||
assert_file_contains "$SELFDEP/update.sh.dist" "THIS-IS-THE-NEW-UPDATE-SH" "update.sh.dist holds the target's version"
|
||||
assert_file_contains "$WORK/self.log" "update.sh.dist" "summary points the user at update.sh.dist"
|
||||
|
||||
# --- summary -----------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Result: $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" = "0" ] || exit 1
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Validate the upgrade manifest (upgrades.json), which update.sh reads with jq.
|
||||
#
|
||||
# - json: upgrades.json is valid JSON
|
||||
# - keys: top-level keys are bare-semver versions (e.g. "0.7.0"), plus the
|
||||
# optional "_schema" documentation block
|
||||
# - schema: each version-keyed entry has only known fields, with valid types
|
||||
# (an unknown/misspelled key like "breakng" would silently disarm
|
||||
# its gate, so it is rejected here)
|
||||
# - gate: any non-null "gate" points at a script that exists in the repo
|
||||
#
|
||||
# The manifest is the source of truth for gating; the CHANGELOG is display only,
|
||||
# so this test deliberately does NOT cross-check the two. Requires jq (already a
|
||||
# runtime dependency of update.sh); no yq, no generation step.
|
||||
#
|
||||
# Usage:
|
||||
# sh tests/test-upgrades-manifest.sh # run from the docker/ directory
|
||||
#
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
|
||||
cd "$DOCKER_DIR"
|
||||
|
||||
JSON=upgrades.json
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required"; exit 1; }
|
||||
[ -f "$JSON" ] || { echo "ERROR: $JSON missing"; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT INT TERM
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
|
||||
|
||||
echo ""
|
||||
echo "=== upgrades.json is valid JSON ==="
|
||||
if jq -e . "$JSON" >/dev/null 2>"$TMP/err"; then
|
||||
ok "upgrades.json parses"
|
||||
else
|
||||
bad "upgrades.json is not valid JSON: $(cat "$TMP/err" 2>/dev/null)"
|
||||
echo "=== Result: $PASS passed, $FAIL failed ==="; exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== top-level keys are versions (or _schema) ==="
|
||||
for k in $(jq -r 'keys[]' "$JSON"); do
|
||||
case "$k" in
|
||||
_schema) ok "doc block '_schema' present" ;;
|
||||
[0-9]*.[0-9]*) ok "version key $k" ;;
|
||||
*) bad "unexpected top-level key '$k' (want bare semver like 0.7.0)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== version-keyed entries have only known fields, with valid types ==="
|
||||
for k in $(jq -r 'keys[]' "$JSON"); do
|
||||
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
|
||||
errs=$(jq -r --arg k "$k" '.[$k] as $e
|
||||
| (($e | keys) - ["breaking", "gate", "migration_guide_url", "requires"]) as $unknown
|
||||
| [ (if ($e.breaking != null) and (($e.breaking|type) != "boolean") then "breaking must be bool" else empty end),
|
||||
(if ($e.gate != null) and (($e.gate|type) != "string") then "gate must be string|null" else empty end),
|
||||
(if ($e.migration_guide_url != null) and (($e.migration_guide_url|type) != "string") then "migration_guide_url must be string|null" else empty end),
|
||||
(if ($e.requires != null) and (($e.requires|type) != "array") then "requires must be array" else empty end),
|
||||
(if ($unknown | length) > 0 then "unknown field(s) (typo?): " + ($unknown | join(", ")) else empty end)
|
||||
] | join("; ")' "$JSON")
|
||||
if [ -z "$errs" ]; then ok "entry $k valid"; else bad "entry $k: $errs"; fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== gate scripts referenced by entries exist ==="
|
||||
checked=0
|
||||
for k in $(jq -r 'keys[]' "$JSON"); do
|
||||
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
|
||||
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$JSON")
|
||||
[ -n "$gate" ] || continue
|
||||
checked=$((checked+1))
|
||||
if [ -f "$gate" ]; then
|
||||
ok "gate for $k exists: $gate"
|
||||
else
|
||||
bad "gate for $k missing: $gate"
|
||||
fi
|
||||
done
|
||||
[ "$checked" = 0 ] && echo " (no entries reference a gate script)"
|
||||
|
||||
echo ""
|
||||
echo "=== Result: $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" = 0 ] || exit 1
|
||||
@@ -1,692 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Update an existing self-hosted Supabase deployment in place.
|
||||
#
|
||||
# The deployment directory mixes vendor-owned files (docker-compose.yml, the
|
||||
# override files, volumes/*, scripts, .env.example) with user-owned state
|
||||
# (.env, docker-compose.override.yml, volumes/db/data, volumes/storage, etc.).
|
||||
# This script pulls a newer version of the Supabase files on top of yours
|
||||
# using a 3-way merge against the version you started from, so local edits
|
||||
# survive and genuine conflicts are surfaced rather than silently overwritten.
|
||||
#
|
||||
# The version you started from is recorded in .supabase-version (written by
|
||||
# setup.sh). If it is missing, pass --from <ref> or follow the printed guidance.
|
||||
#
|
||||
# What it never touches: .env values you set, docker-compose.override.yml, and
|
||||
# the data directories (volumes/db/data, volumes/storage, etc.). New keys from
|
||||
# .env.example are appended to your .env; existing values are kept as-is.
|
||||
#
|
||||
# By default it updates to the latest self-hosted/v* release tag (or 'master'
|
||||
# until the first tag exists). Pass --to to pin a specific tag/branch.
|
||||
#
|
||||
# Usage:
|
||||
# sh update.sh # update to the latest release tag
|
||||
# sh update.sh --dry-run # show what would change, write nothing
|
||||
# sh update.sh --to <tag> # update to a specific tag/branch
|
||||
# sh update.sh --from <ref> # base to merge from (if no .supabase-version)
|
||||
# sh update.sh --yes # don't prompt, even on breaking changes
|
||||
#
|
||||
# Env:
|
||||
# SUPABASE_REPO_URL Override the upstream repo (default: github supabase/supabase)
|
||||
#
|
||||
# Documentation: https://supabase.com/docs/guides/self-hosting/updating
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Pipeline (see main at the bottom):
|
||||
# resolve refs -> fetch base+target snapshots -> [report-only exit]
|
||||
# -> build manifest gate -> confirm_gate (before any writes)
|
||||
# -> backup → merge vendor files + .env keys -> summary → stamp
|
||||
#
|
||||
# Three trees for every vendor file path:
|
||||
# - base - upstream at BASE_REF (.supabase-version ref=, or --from)
|
||||
# - target - upstream at TARGET_REF (--to, or latest self-hosted/v* tag)
|
||||
# - user's - the deployment directory (cwd); _not_ a git checkout
|
||||
#
|
||||
# Git is only used to fetch snapshots (fetch_snapshot) and to run git merge-file.
|
||||
#
|
||||
# Breaking-change gate (upgrades.json on the target snapshot):
|
||||
# - Keyed by version (e.g. "0.7.0"); window = entries in (BASE_VER, TARGET_VER],
|
||||
# where the bounds are the base/target refs reduced to bare semver
|
||||
# (self-hosted/vX.Y.Z -> X.Y.Z) and compared with sort -V.
|
||||
# - If a bound is not a release tag (a commit SHA, or "master"), that side of
|
||||
# the window is left open and all applicable entries are shown with a warning.
|
||||
# - Prompt when an entry has breaking:true or a gate script; runs before
|
||||
# backup/merge so abort leaves the deployment untouched.
|
||||
# - CHANGELOG.md is never parsed (update.sh only points users at it); routine
|
||||
# "requires compose update" items are applied by the merge, not listed here.
|
||||
#
|
||||
# User-owned paths skipped during merge are defined in .gitignore (loaded from the
|
||||
# target snapshot). git check-ignore --no-index applies negation rules (e.g.
|
||||
# volumes/functions/** ignored except volumes/functions/main/index.ts).
|
||||
# .git/ is excluded here only - never listed in .gitignore.
|
||||
#
|
||||
# .env is never 3-way merged: append missing keys from .env.example only.
|
||||
|
||||
# --- globals (set during main) -----------------------------------------------
|
||||
|
||||
REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}"
|
||||
STAMP_FILE=".supabase-version"
|
||||
SELF_NAME=$(basename "$0")
|
||||
DRY_RUN=0
|
||||
ASSUME_YES=0
|
||||
TO_REF=""
|
||||
FROM_REF=""
|
||||
|
||||
TARGET_REF=""
|
||||
BASE_REF=""
|
||||
BASE_VER=""
|
||||
TARGET_VER=""
|
||||
REPORT_ONLY=0
|
||||
|
||||
TMP_ROOT=""
|
||||
TARGET_DIR=""
|
||||
BASE_DIR=""
|
||||
REPORT=""
|
||||
ENV_ADDED=""
|
||||
ENV_REMOVED=""
|
||||
GATE_REPORT=""
|
||||
GATE_REQUIRED=0
|
||||
IGNORE_FILE=""
|
||||
IGNORE_GIT_DIR=""
|
||||
|
||||
# --- logging -----------------------------------------------------------------
|
||||
|
||||
log() { printf "===> %s\n" "$*"; }
|
||||
warn() { printf "WARNING: %s\n" "$*" >&2; }
|
||||
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
print_help() {
|
||||
awk 'NR==1 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0"
|
||||
}
|
||||
|
||||
# --- small helpers -----------------------------------------------------------
|
||||
|
||||
# load_ignore_file - vendor/user split from the target snapshot's .gitignore.
|
||||
# Uses a throwaway git dir so check-ignore works on deployment trees that are
|
||||
# not git repos (and on Apple Git, which rejects --git-dir=/dev/null).
|
||||
load_ignore_file() {
|
||||
if [ -f "$TARGET_DIR/.gitignore" ]; then
|
||||
IGNORE_FILE="$TARGET_DIR/.gitignore"
|
||||
elif [ -f .gitignore ]; then
|
||||
IGNORE_FILE="$(pwd)/.gitignore"
|
||||
else
|
||||
IGNORE_FILE=""
|
||||
warn "No .gitignore found; only .git paths are excluded from the merge."
|
||||
return 0
|
||||
fi
|
||||
case "$IGNORE_FILE" in
|
||||
/*) ;;
|
||||
*) IGNORE_FILE="$(cd "$(dirname "$IGNORE_FILE")" && pwd)/$(basename "$IGNORE_FILE")" ;;
|
||||
esac
|
||||
IGNORE_GIT_DIR="$TMP_ROOT/ignore-git"
|
||||
git init -q "$IGNORE_GIT_DIR"
|
||||
}
|
||||
|
||||
# is_excluded <relpath> - true when the path is user-owned and must not merge.
|
||||
is_excluded() {
|
||||
case "$1" in
|
||||
.git|.git/*) return 0 ;;
|
||||
esac
|
||||
[ -n "$IGNORE_FILE" ] || return 1
|
||||
git -C "$IGNORE_GIT_DIR" -c "core.excludesfile=$IGNORE_FILE" \
|
||||
check-ignore -q --no-index "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
read_stamp_ref() {
|
||||
[ -f "$STAMP_FILE" ] || return 1
|
||||
val=$(grep -E '^ref=' "$STAMP_FILE" 2>/dev/null | head -n1 | cut -d= -f2- | tr -d "\r\"' ")
|
||||
if [ -z "$val" ]; then
|
||||
val=$(grep -vE '^[[:space:]]*#' "$STAMP_FILE" 2>/dev/null \
|
||||
| grep -vE '^[[:space:]]*$' | head -n1 | tr -d "\r\"' ")
|
||||
fi
|
||||
[ -n "$val" ] && printf '%s' "$val"
|
||||
}
|
||||
|
||||
# env_has_key <key> <file> - true if the key appears as KEY=, even commented
|
||||
# (e.g. "#GOOGLE_ENABLED="), so we never re-add a key the user disabled on purpose.
|
||||
env_has_key() {
|
||||
grep -qE "^[[:space:]]*#?[[:space:]]*$1=" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
record() { printf '%s:%s\n' "$1" "$2" >> "$REPORT"; }
|
||||
|
||||
count_status() { grep -cE "^$1:" "$REPORT" 2>/dev/null || true; }
|
||||
|
||||
list_status() {
|
||||
grep -E "^$1:" "$REPORT" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# --- upstream snapshots ------------------------------------------------------
|
||||
|
||||
_sparse_init() {
|
||||
git -C "$1" init -q
|
||||
git -C "$1" remote add origin "$REPO_URL"
|
||||
git -C "$1" config core.sparseCheckout true
|
||||
git -C "$1" sparse-checkout init --cone >/dev/null 2>&1
|
||||
git -C "$1" sparse-checkout set docker >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# fetch_snapshot <ref> <dest_dir>
|
||||
# Materializes ./docker at <ref> into <dest_dir> via a shallow fetch. Also the
|
||||
# seam an artifact source (tarball + sha256) would slot into later.
|
||||
fetch_snapshot() {
|
||||
_ref="$1"
|
||||
_dest="$2"
|
||||
_work=$(mktemp -d "$TMP_ROOT/fetch.XXXXXX")
|
||||
if _sparse_init "$_work" \
|
||||
&& git -C "$_work" fetch --depth=1 --filter=blob:none -q origin "$_ref" 2>/dev/null \
|
||||
&& git -C "$_work" checkout -q FETCH_HEAD 2>/dev/null \
|
||||
&& [ -d "$_work/docker" ]; then
|
||||
mkdir -p "$_dest"
|
||||
cp -rf "$_work/docker/." "$_dest/"
|
||||
rm -rf "$_work"
|
||||
else
|
||||
rm -rf "$_work"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
latest_release_tag() {
|
||||
git ls-remote --tags --refs "$REPO_URL" 2>/dev/null \
|
||||
| sed 's#^.*refs/tags/##' \
|
||||
| grep -E '^self-hosted/v[0-9]' \
|
||||
| sort -V | tail -n1
|
||||
}
|
||||
|
||||
list_files() {
|
||||
( cd "$1" && find . -type f | sed 's|^\./||' | grep -vE '^\.git(/|$)' | sort )
|
||||
}
|
||||
|
||||
# normalize_version <ref> - reduce a ref to bare semver (0.7.0) for comparison,
|
||||
# or empty when it is not a self-hosted release tag (commit SHA, "master", …).
|
||||
normalize_version() {
|
||||
_v="${1#refs/tags/}"
|
||||
_v="${_v#self-hosted/}"
|
||||
_v="${_v#v}"
|
||||
case "$_v" in
|
||||
[0-9]*.[0-9]*) printf '%s' "$_v" ;;
|
||||
*) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ver_gt A B - true when version A is strictly greater than B (sort -V order).
|
||||
ver_gt() {
|
||||
if [ "$1" = "$2" ]; then return 1; fi
|
||||
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
|
||||
}
|
||||
|
||||
# ver_in_window VER - true when BASE_VER < VER <= TARGET_VER. An empty bound
|
||||
# leaves that side open (over-reports a gate rather than hiding one).
|
||||
ver_in_window() {
|
||||
if [ -n "$TARGET_VER" ] && ver_gt "$1" "$TARGET_VER"; then return 1; fi
|
||||
if [ -n "$BASE_VER" ] && ! ver_gt "$1" "$BASE_VER"; then return 1; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- ref resolution ----------------------------------------------------------
|
||||
|
||||
resolve_target_ref() {
|
||||
if [ -n "$TO_REF" ]; then
|
||||
TARGET_REF="$TO_REF"
|
||||
return
|
||||
fi
|
||||
TARGET_REF=$(latest_release_tag)
|
||||
if [ -n "$TARGET_REF" ]; then
|
||||
log "Latest release tag: $TARGET_REF"
|
||||
else
|
||||
TARGET_REF="master"
|
||||
warn "No self-hosted/v* release tags found; targeting 'master'. Pin a version with --to."
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_base_ref() {
|
||||
REPORT_ONLY=0
|
||||
if [ -n "$FROM_REF" ]; then
|
||||
BASE_REF="$FROM_REF"
|
||||
elif BASE_REF=$(read_stamp_ref) && [ -n "$BASE_REF" ]; then
|
||||
:
|
||||
else
|
||||
REPORT_ONLY=1
|
||||
BASE_REF=""
|
||||
fi
|
||||
}
|
||||
|
||||
print_report_only_guidance() {
|
||||
warn "No $STAMP_FILE found and no --from <ref> given; cannot determine the version you started from."
|
||||
cat >&2 <<EOF
|
||||
|
||||
Without a base version a safe 3-way merge is not possible. To fix this, find the
|
||||
version your deployment was based on and record it, then re-run:
|
||||
|
||||
1. Identify your base - the self-hosted/vX.Y.Z release (or commit) your files
|
||||
came from. Cross-reference the image tags in docker-compose.yml / .env
|
||||
against versions.md, or the newest CHANGELOG.md section you remember pulling.
|
||||
2. Write it to $STAMP_FILE, e.g.:
|
||||
printf 'ref=self-hosted/v0.7.0\n' > $STAMP_FILE
|
||||
3. Re-run: sh update.sh
|
||||
|
||||
Or supply it inline for this run: sh update.sh --from <commit-sha-or-tag>
|
||||
|
||||
Continuing in REPORT-ONLY mode. NOTE: this is NOT the full set of changes -
|
||||
without a base version it can only list files and .env keys that are entirely
|
||||
NEW to you; it CANNOT show which existing files would change or conflict. Record
|
||||
a base and re-run for the real preview. Nothing will be written.
|
||||
EOF
|
||||
}
|
||||
|
||||
fetch_snapshots() {
|
||||
TARGET_DIR="$TMP_ROOT/target"
|
||||
log "Fetching target snapshot ($TARGET_REF)"
|
||||
fetch_snapshot "$TARGET_REF" "$TARGET_DIR" \
|
||||
|| die "Could not fetch target snapshot '$TARGET_REF' from $REPO_URL"
|
||||
|
||||
if [ "$REPORT_ONLY" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
BASE_DIR="$TMP_ROOT/base"
|
||||
log "Fetching base snapshot ($BASE_REF)"
|
||||
fetch_snapshot "$BASE_REF" "$BASE_DIR" \
|
||||
|| die "Could not fetch base snapshot '$BASE_REF' from $REPO_URL"
|
||||
}
|
||||
|
||||
run_report_only() {
|
||||
log "Files in '$TARGET_REF' you do NOT have yet (brand-new only; existing files that changed are NOT shown here):"
|
||||
while IFS= read -r f; do
|
||||
is_excluded "$f" && continue
|
||||
[ -f "$f" ] || echo " + $f"
|
||||
done <<EOF
|
||||
$(list_files "$TARGET_DIR")
|
||||
EOF
|
||||
echo ""
|
||||
log ".env keys in the new .env.example that your .env is missing (add these):"
|
||||
if [ -f "$TARGET_DIR/.env.example" ]; then
|
||||
while IFS= read -r k; do
|
||||
env_has_key "$k" .env || echo " + $k"
|
||||
done <<EOF
|
||||
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$TARGET_DIR/.env.example" | cut -d= -f1)
|
||||
EOF
|
||||
fi
|
||||
echo ""
|
||||
warn "This is NOT the full update. To see what would actually change, record a base version."
|
||||
warn "See the guidance above and re-run the script. Nothing was written."
|
||||
}
|
||||
|
||||
# --- breaking-change gate (manifest-driven, before any writes) ---------------
|
||||
# Reads docker/upgrades.json from the target snapshot; gating keys off manifest
|
||||
# entries in (BASE_VER, TARGET_VER].
|
||||
|
||||
build_gate_report() {
|
||||
_manifest="$TARGET_DIR/upgrades.json"
|
||||
GATE_REQUIRED=0
|
||||
: > "$GATE_REPORT"
|
||||
|
||||
if [ ! -f "$_manifest" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! jq -e 'type == "object"' "$_manifest" >/dev/null 2>&1; then
|
||||
die "$_manifest is present but is not a valid JSON object; refusing to update without a working breaking-change gate. Please report this to the maintainers."
|
||||
fi
|
||||
|
||||
if [ -z "$BASE_VER" ] || [ -z "$TARGET_VER" ]; then
|
||||
warn "Base or target is not a self-hosted/vX.Y.Z tag; cannot compute an exact update window."
|
||||
warn "Showing all applicable manual-action releases."
|
||||
warn "Review which ones apply to your deployment."
|
||||
fi
|
||||
|
||||
for k in $(jq -r 'keys[]' "$_manifest" 2>/dev/null); do
|
||||
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
|
||||
ver_in_window "$k" || continue
|
||||
|
||||
breaking=$(jq -r --arg k "$k" '.[$k].breaking // false' "$_manifest")
|
||||
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$_manifest")
|
||||
url=$(jq -r --arg k "$k" '.[$k].migration_guide_url // empty' "$_manifest")
|
||||
reqs=$(jq -r --arg k "$k" '.[$k].requires[]? // empty' "$_manifest")
|
||||
|
||||
if [ "$breaking" = "true" ] || [ -n "$gate" ]; then
|
||||
GATE_REQUIRED=1
|
||||
fi
|
||||
|
||||
{
|
||||
[ "$breaking" = "true" ] && echo "[$k] BREAKING" || echo "[$k]"
|
||||
[ -n "$gate" ] && echo " gate: '$gate' must be run first (see the steps below for the exact command)"
|
||||
[ -n "$url" ] && echo " guide: $url"
|
||||
[ -n "$reqs" ] && printf '%s\n' "$reqs" | sed 's/^/ - /'
|
||||
} >> "$GATE_REPORT"
|
||||
done
|
||||
# Explicit success: the loop's last iteration can end on a false test (e.g.
|
||||
# an entry with no 'requires'), which would otherwise make this function
|
||||
# return non-zero and abort the whole script under 'set -e'.
|
||||
return 0
|
||||
}
|
||||
|
||||
confirm_gate() {
|
||||
[ "$GATE_REQUIRED" = "1" ] || return 0
|
||||
[ "$DRY_RUN" != "1" ] || return 0
|
||||
[ "$ASSUME_YES" != "1" ] || return 0
|
||||
|
||||
echo "" >&2
|
||||
warn "This update requires manual action - review before continuing:"
|
||||
sed 's/^/ /' "$GATE_REPORT" >&2
|
||||
echo "" >&2
|
||||
|
||||
if { : > /dev/tty; } 2>/dev/null; then
|
||||
printf "Have you completed the required steps and want to continue? [y/N]: " > /dev/tty
|
||||
read -r reply < /dev/tty
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) return 0 ;;
|
||||
*) die "Aborted by user. Nothing was modified." ;;
|
||||
esac
|
||||
fi
|
||||
die "Breaking/gated changes present and no controlling terminal to confirm. Re-run with --yes to proceed."
|
||||
}
|
||||
|
||||
# --- backup ------------------------------------------------------------------
|
||||
|
||||
take_backup() {
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "Dry run: no backup taken, nothing will be written."
|
||||
return 0
|
||||
fi
|
||||
mkdir -p backups
|
||||
_backup="backups/pre-update-$(date +%Y%m%d-%H%M%S).tgz"
|
||||
log "Backing up current configuration to $_backup (excluding data directories)"
|
||||
tar czf "$_backup" \
|
||||
--exclude='./backups' \
|
||||
--exclude='./volumes/db/data' \
|
||||
--exclude='./volumes/storage' \
|
||||
. 2>/dev/null || warn "Backup archive reported errors; review $_backup before relying on it."
|
||||
warn "This does NOT back up your database. Back it up separately before updating."
|
||||
}
|
||||
|
||||
# --- vendor file merge -------------------------------------------------------
|
||||
|
||||
apply_file() {
|
||||
[ "$DRY_RUN" = "1" ] && return 0
|
||||
_dir=$(dirname "$1")
|
||||
[ "$_dir" = "." ] || mkdir -p "$_dir"
|
||||
cp -f "$2" "$1"
|
||||
}
|
||||
|
||||
# Per-file 3-way merge (u=yours, b=base snapshot, t=target snapshot):
|
||||
# no t → keep u; report removed-upstream
|
||||
# no u → copy t; report new
|
||||
# u == t → report unchanged
|
||||
# u == b → copy t; report updated (user never edited)
|
||||
# else → git merge-file u b t; report merged-clean or CONFLICT
|
||||
# If b had no file, an empty file stands in for b.
|
||||
merge_one_file() {
|
||||
f="$1"
|
||||
empty="$2"
|
||||
merged="$TMP_ROOT/merged.out"
|
||||
|
||||
b="$BASE_DIR/$f"
|
||||
t="$TARGET_DIR/$f"
|
||||
u="$f"
|
||||
|
||||
if [ ! -f "$t" ]; then
|
||||
[ -f "$u" ] && record "removed-upstream" "$f"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$u" ]; then
|
||||
apply_file "$f" "$t"
|
||||
record "new" "$f"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if cmp -s "$u" "$t"; then
|
||||
record "unchanged" "$f"
|
||||
return 0
|
||||
fi
|
||||
|
||||
base_for_merge="$b"
|
||||
[ -f "$base_for_merge" ] || base_for_merge="$empty"
|
||||
|
||||
if cmp -s "$u" "$base_for_merge"; then
|
||||
apply_file "$f" "$t"
|
||||
record "updated" "$f"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if git merge-file -p -q \
|
||||
-L "yours ($f)" -L "base" -L "new ($TARGET_REF)" \
|
||||
"$u" "$base_for_merge" "$t" > "$merged" 2>/dev/null; then
|
||||
[ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u"
|
||||
record "merged-clean" "$f"
|
||||
elif [ -s "$merged" ]; then
|
||||
# Non-zero exit with output = a normal conflict (markers written).
|
||||
[ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u"
|
||||
record "CONFLICT" "$f"
|
||||
else
|
||||
# git merge-file errored and produced no output; keep the user's file
|
||||
# intact rather than truncating it to empty.
|
||||
record "merge-failed" "$f"
|
||||
fi
|
||||
}
|
||||
|
||||
# The running script is a vendor file too, but overwriting it in place would
|
||||
# corrupt this process (the shell reads $0 as it runs). Never write it directly:
|
||||
# if the target ships a different version, stage it as <name>.dist to review.
|
||||
stage_self_update() {
|
||||
_t="$TARGET_DIR/$SELF_NAME"
|
||||
if [ ! -f "$_t" ] || cmp -s "$SELF_NAME" "$_t"; then
|
||||
record "unchanged" "$SELF_NAME"
|
||||
return 0
|
||||
fi
|
||||
[ "$DRY_RUN" = "1" ] || cp -f "$_t" "$SELF_NAME.dist"
|
||||
record "self-staged" "$SELF_NAME"
|
||||
}
|
||||
|
||||
merge_vendor_files() {
|
||||
_empty="$TMP_ROOT/empty"
|
||||
: > "$_empty"
|
||||
: > "$REPORT"
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
is_excluded "$f" && continue
|
||||
if [ "$f" = "$SELF_NAME" ]; then
|
||||
stage_self_update
|
||||
continue
|
||||
fi
|
||||
merge_one_file "$f" "$_empty"
|
||||
done <<EOF
|
||||
$( { list_files "$BASE_DIR"; list_files "$TARGET_DIR"; } | sort -u )
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- .env key-union merge ----------------------------------------------------
|
||||
|
||||
merge_env_file() {
|
||||
_example="$TARGET_DIR/.env.example"
|
||||
: > "$ENV_ADDED"
|
||||
: > "$ENV_REMOVED"
|
||||
[ -f "$_example" ] || return 0
|
||||
|
||||
while IFS= read -r k; do
|
||||
env_has_key "$k" .env || echo "$k" >> "$ENV_ADDED"
|
||||
done <<EOF
|
||||
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$_example" | cut -d= -f1)
|
||||
EOF
|
||||
|
||||
while IFS= read -r k; do
|
||||
env_has_key "$k" "$_example" || echo "$k" >> "$ENV_REMOVED"
|
||||
done <<EOF
|
||||
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | cut -d= -f1)
|
||||
EOF
|
||||
|
||||
if [ ! -s "$ENV_ADDED" ] || [ "$DRY_RUN" = "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "############"
|
||||
echo "# Added by update.sh on $(date +%Y-%m-%d) from .env.example ($TARGET_REF)."
|
||||
echo "# Review and set values as needed."
|
||||
echo "############"
|
||||
while IFS= read -r k; do
|
||||
grep -E "^${k}=" "$_example" | head -n1
|
||||
done < "$ENV_ADDED"
|
||||
} >> .env
|
||||
}
|
||||
|
||||
# --- summary and finish ------------------------------------------------------
|
||||
|
||||
print_summary() {
|
||||
echo ""
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "DRY RUN - the following WOULD change (nothing was written):"
|
||||
else
|
||||
log "Update applied. Summary:"
|
||||
fi
|
||||
printf " updated: %s\n" "$(count_status updated)"
|
||||
printf " new: %s\n" "$(count_status new)"
|
||||
printf " merged (clean): %s\n" "$(count_status merged-clean)"
|
||||
printf " CONFLICTS: %s\n" "$(count_status CONFLICT)"
|
||||
printf " merge failures: %s\n" "$(count_status merge-failed)"
|
||||
printf " removed upstream: %s\n" "$(count_status removed-upstream)"
|
||||
printf " env keys added: %s\n" "$( [ -s "$ENV_ADDED" ] && wc -l < "$ENV_ADDED" | tr -d ' ' || echo 0 )"
|
||||
|
||||
if [ "$(count_status CONFLICT)" != "0" ]; then
|
||||
echo ""
|
||||
warn "Files with merge conflicts (edit these and remove the <<<<<<< ======= >>>>>>> markers):"
|
||||
list_status CONFLICT
|
||||
fi
|
||||
if [ "$(count_status merge-failed)" != "0" ]; then
|
||||
echo ""
|
||||
warn "Files git could not merge (left unchanged - update these manually):"
|
||||
list_status merge-failed
|
||||
fi
|
||||
if [ "$(count_status merged-clean)" != "0" ]; then
|
||||
echo ""
|
||||
log "Files merged cleanly (review recommended):"
|
||||
list_status merged-clean
|
||||
fi
|
||||
if [ "$(count_status removed-upstream)" != "0" ]; then
|
||||
echo ""
|
||||
log "Removed upstream but kept in place (you may no longer need these):"
|
||||
list_status removed-upstream
|
||||
fi
|
||||
if [ "$(count_status self-staged)" != "0" ]; then
|
||||
echo ""
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "$SELF_NAME differs from the version in '$TARGET_REF'; a real run would stage that version as $SELF_NAME.dist (the running script is never overwritten in place)."
|
||||
else
|
||||
log "$SELF_NAME differs from the version in '$TARGET_REF', staged as $SELF_NAME.dist (the running script was not modified)."
|
||||
log "Review it, then swap it in if needed: mv $SELF_NAME.dist $SELF_NAME"
|
||||
fi
|
||||
fi
|
||||
if [ -s "$ENV_ADDED" ]; then
|
||||
echo ""
|
||||
log ".env keys added (review values):"
|
||||
sed 's/^/ + /' "$ENV_ADDED"
|
||||
fi
|
||||
if [ -s "$ENV_REMOVED" ]; then
|
||||
echo ""
|
||||
log ".env keys you have that are gone from the new .env.example (review/remove manually):"
|
||||
sed 's/^/ - /' "$ENV_REMOVED"
|
||||
fi
|
||||
if [ -s "$GATE_REPORT" ]; then
|
||||
echo ""
|
||||
log "Required manual steps for this update (from upgrades.json):"
|
||||
sed 's/^/ /' "$GATE_REPORT"
|
||||
fi
|
||||
echo ""
|
||||
log "For what changed in this update, see CHANGELOG.md (from $BASE_REF to $TARGET_REF)."
|
||||
}
|
||||
|
||||
write_stamp() {
|
||||
{
|
||||
echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh."
|
||||
echo "# Do not commit or edit by hand. Records the ref this deployment was based on."
|
||||
echo "ref=$TARGET_REF"
|
||||
} > "$STAMP_FILE"
|
||||
}
|
||||
|
||||
print_next_steps() {
|
||||
echo ""
|
||||
log "Next steps:"
|
||||
echo " 1. Review the changes above (compare against the latest backup in backups/ if needed)."
|
||||
echo " 2. sh run.sh pull"
|
||||
echo " 3. sh run.sh recreate"
|
||||
}
|
||||
|
||||
# --- main --------------------------------------------------------------------
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--to) [ $# -ge 2 ] || die "--to requires a <tag> argument."; TO_REF="$2"; shift 2 ;;
|
||||
--from) [ $# -ge 2 ] || die "--from requires a <ref> argument."; FROM_REF="$2"; shift 2 ;;
|
||||
-h|--help) print_help; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; print_help; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f docker-compose.yml ] || die "docker-compose.yml not found in $(pwd). Run this from your deployment directory."
|
||||
[ -f .env ] || die ".env not found in $(pwd). This does not look like a configured deployment."
|
||||
command -v git >/dev/null 2>&1 || die "git is required but was not found on PATH."
|
||||
command -v jq >/dev/null 2>&1 || die "jq is required but was not found on PATH."
|
||||
|
||||
resolve_target_ref
|
||||
resolve_base_ref
|
||||
TARGET_VER=$(normalize_version "$TARGET_REF")
|
||||
BASE_VER=$(normalize_version "$BASE_REF")
|
||||
[ "$REPORT_ONLY" = "1" ] && print_report_only_guidance
|
||||
|
||||
TMP_ROOT=$(mktemp -d)
|
||||
REPORT="$TMP_ROOT/report"
|
||||
ENV_ADDED="$TMP_ROOT/env_added"
|
||||
ENV_REMOVED="$TMP_ROOT/env_removed"
|
||||
GATE_REPORT="$TMP_ROOT/gate_report"
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM
|
||||
|
||||
fetch_snapshots
|
||||
load_ignore_file
|
||||
|
||||
if [ "$REPORT_ONLY" = "1" ]; then
|
||||
run_report_only
|
||||
build_gate_report
|
||||
if [ -s "$GATE_REPORT" ]; then
|
||||
echo ""
|
||||
log "Breaking changes / required manual steps in this range (from upgrades.json):"
|
||||
sed 's/^/ /' "$GATE_REPORT"
|
||||
warn "Record a base version (see above) and re-run to apply with the gate enforced."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
build_gate_report
|
||||
confirm_gate
|
||||
|
||||
take_backup
|
||||
merge_vendor_files
|
||||
merge_env_file
|
||||
print_summary
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo ""
|
||||
log "Dry run complete. Re-run without --dry-run to apply."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(count_status CONFLICT)" != "0" ] || [ "$(count_status merge-failed)" != "0" ]; then
|
||||
echo ""
|
||||
warn "Update applied WITH CONFLICTS. Resolve the files listed above (remove the"
|
||||
warn "<<<<<<< ======= >>>>>>> markers, or fix the files git could not merge)"
|
||||
warn "before starting the stack."
|
||||
warn "The version stamp was NOT advanced; it will update on your next clean run."
|
||||
warn "Exiting with status 2."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
write_stamp
|
||||
print_next_steps
|
||||
log "Update applied cleanly."
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"_schema": {
|
||||
"description": "Manual-action manifest for self-hosted upgrades, read by update.sh with jq. Hand-edit this file directly; it is the source of truth (no generation step). Keys are self-hosted release versions as bare semver (e.g. \"0.7.0\"), matching the self-hosted/vX.Y.Z tag and the \"## [0.7.0]\" CHANGELOG heading. Only add an entry for a release that needs an action a file diff cannot encode: a data migration, a run-this-first script, or a breaking default. Routine config changes are applied automatically by update.sh's 3-way merge and must NOT be listed here. update.sh gates on entries in (your version, target version], ordered with sort -V.",
|
||||
"fields": {
|
||||
"breaking": "bool - requires explicit user confirmation before applying",
|
||||
"gate": "string|null - script to run before upgrading past this release (e.g. utils/upgrade-pg17.sh)",
|
||||
"migration_guide_url": "string|null - link to the per-release migration guide",
|
||||
"requires": "string[] - free-text manual steps shown to the user"
|
||||
}
|
||||
},
|
||||
"0.6.0": {
|
||||
"breaking": true,
|
||||
"gate": "utils/upgrade-pg17.sh",
|
||||
"migration_guide_url": "https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17",
|
||||
"requires": [
|
||||
"Postgres 17 is now the default. Do NOT start Postgres 17 against an existing Postgres 15 data directory - back up your database first.",
|
||||
"Run 'sudo bash utils/upgrade-pg17.sh' (needs bash + root) to migrate Postgres 15 -> 17, then recreate containers. To defer, pin Postgres 15 with the docker-compose.pg15.yml override.",
|
||||
"Includes a security fix for the API gateway (Realtime /api/tenants and /api/openapi routes) - strongly recommended for any instance running Realtime."
|
||||
]
|
||||
},
|
||||
"0.7.0": {
|
||||
"breaking": true,
|
||||
"gate": null,
|
||||
"migration_guide_url": "https://github.com/orgs/supabase/discussions/47093",
|
||||
"requires": [
|
||||
"API_EXTERNAL_URL now includes the /auth/v1 path prefix, and SAML SSO endpoints moved to /auth/v1/sso/saml/*. Update custom OAuth provider callback URLs and any SAML configuration accordingly.",
|
||||
"Anon (publishable) key access to the OpenAPI spec at /rest/v1/ has been removed. Use the service role or secret API key if you relied on it; normal data access via /rest/v1/<table> is unaffected."
|
||||
]
|
||||
},
|
||||
"0.8.0": {
|
||||
"breaking": true,
|
||||
"gate": null,
|
||||
"migration_guide_url": "https://github.com/orgs/supabase/discussions/48048",
|
||||
"requires": [
|
||||
"Envoy is now the default API gateway, replacing Kong. The gateway service is renamed from 'kong' to 'api-gw' (container 'supabase-envoy'); the 'kong' network alias still resolves, so internal service references keep working.",
|
||||
"If you customized volumes/api/kong.yml or the gateway service, enable the Kong override with 'sh run.sh config add kong' to keep running Kong; otherwise the merge switches you to Envoy."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Add asymmetric key pair and opaque API keys to a self-hosted Supabase installation.
|
||||
#
|
||||
# Reads JWT_SECRET from .env and generates:
|
||||
# - EC P-256 key pair (JWT_KEYS, JWT_JWKS)
|
||||
# - Opaque API keys (SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY)
|
||||
# - Internal: ES256 JWT API keys (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC)
|
||||
#
|
||||
# Usage:
|
||||
# sh add-new-auth-keys.sh # Interactive: prints keys, prompts to update .env
|
||||
# sh add-new-auth-keys.sh --update-env # Prints keys and writes them to .env
|
||||
# sh add-new-auth-keys.sh | tee keys # Non-interactive: prints keys only
|
||||
#
|
||||
# Prerequisites:
|
||||
# - .env file with JWT_SECRET set (run generate-keys.sh first)
|
||||
# - node (>= 16) or docker
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
node_ok() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
[ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null
|
||||
}
|
||||
|
||||
# Resolve how to run node: local install (>= 16) preferred, docker fallback.
|
||||
if node_ok; then
|
||||
node_runner="node"
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
echo "Local node $(node -v) is too old (need >= 16), falling back to docker."
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: requires either node (>= 16) or docker."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Error: docker is installed but the daemon is not running."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker image inspect node:22-alpine >/dev/null 2>&1; then
|
||||
echo "Pulling node:22-alpine (first-run only)..."
|
||||
docker pull node:22-alpine
|
||||
fi
|
||||
|
||||
node_runner="docker run --rm node:22-alpine node"
|
||||
fi
|
||||
|
||||
# Read JWT_SECRET from .env
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run generate-keys.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jwt_secret=$(grep '^JWT_SECRET=' .env | cut -d= -f2- | tr -d '\r')
|
||||
if [ -z "$jwt_secret" ]; then
|
||||
echo "Error: JWT_SECRET not found in .env. Run generate-keys.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
# Node.js does the crypto-heavy work:
|
||||
# - EC P-256 keypair generation
|
||||
# - JWKS construction (with symmetric key included)
|
||||
# - ES256 JWT signing
|
||||
# - Opaque API key generation with checksum
|
||||
$node_runner -e '
|
||||
const crypto = require("crypto");
|
||||
|
||||
const jwtSecret = process.argv[1];
|
||||
|
||||
// Generate EC P-256 keypair and export as JWK
|
||||
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const jwkPrivate = privateKey.export({ format: "jwk" });
|
||||
|
||||
const kid = crypto.randomUUID();
|
||||
|
||||
// Symmetric key as JWK (base64url-encoded)
|
||||
const octKey = {
|
||||
kty: "oct",
|
||||
k: Buffer.from(jwtSecret).toString("base64url"),
|
||||
alg: "HS256"
|
||||
};
|
||||
|
||||
// JWKS with private key (for Auth to sign tokens)
|
||||
const jwksKeypair = { keys: [
|
||||
{ kty: "EC", kid, use: "sig", key_ops: ["sign", "verify"], alg: "ES256", ext: true,
|
||||
crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y, d: jwkPrivate.d },
|
||||
octKey
|
||||
]};
|
||||
|
||||
// JWKS with public key only (for PostgREST, Realtime, Storage to verify)
|
||||
const jwksPublic = { keys: [
|
||||
{ kty: "EC", kid, use: "sig", key_ops: ["verify"], alg: "ES256", ext: true,
|
||||
crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y },
|
||||
octKey
|
||||
]};
|
||||
|
||||
// Sign ES256 JWT
|
||||
function signES256(payload) {
|
||||
const header = { alg: "ES256", typ: "JWT", kid };
|
||||
const b64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const b64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const data = b64Header + "." + b64Payload;
|
||||
const sig = crypto.sign("SHA256", Buffer.from(data), {
|
||||
key: privateKey,
|
||||
dsaEncoding: "ieee-p1363"
|
||||
}).toString("base64url");
|
||||
return data + "." + sig;
|
||||
}
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 5 * 365 * 24 * 3600; // 5 years
|
||||
|
||||
const anonJwt = signES256({ role: "anon", iss: "supabase", iat, exp });
|
||||
const serviceJwt = signES256({ role: "service_role", iss: "supabase", iat, exp });
|
||||
|
||||
// Generate opaque API keys with checksum
|
||||
const PROJECT_REF = "supabase-self-hosted";
|
||||
|
||||
function generateOpaqueKey(prefix) {
|
||||
const random = crypto.randomBytes(17).toString("base64url").slice(0, 22);
|
||||
const intermediate = prefix + random;
|
||||
const checksum = crypto.createHash("sha256")
|
||||
.update(PROJECT_REF + "|" + intermediate)
|
||||
.digest("base64url")
|
||||
.slice(0, 8);
|
||||
return intermediate + "_" + checksum;
|
||||
}
|
||||
|
||||
const publishableKey = generateOpaqueKey("sb_publishable_");
|
||||
const secretKey = generateOpaqueKey("sb_secret_");
|
||||
|
||||
// Output as KEY=value lines for shell to parse
|
||||
console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey);
|
||||
console.log("SUPABASE_SECRET_KEY=" + secretKey);
|
||||
console.log("ANON_KEY_ASYMMETRIC=" + anonJwt);
|
||||
console.log("SERVICE_ROLE_KEY_ASYMMETRIC=" + serviceJwt);
|
||||
console.log("JWT_KEYS=" + JSON.stringify(jwksKeypair.keys));
|
||||
console.log("JWT_JWKS=" + JSON.stringify(jwksPublic));
|
||||
' "$jwt_secret" > "$tmpdir/output"
|
||||
|
||||
# Read generated values
|
||||
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-)
|
||||
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-)
|
||||
ANON_KEY_ASYMMETRIC=$(grep '^ANON_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-)
|
||||
SERVICE_ROLE_KEY_ASYMMETRIC=$(grep '^SERVICE_ROLE_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-)
|
||||
JWT_KEYS=$(grep '^JWT_KEYS=' "$tmpdir/output" | cut -d= -f2-)
|
||||
JWT_JWKS=$(grep '^JWT_JWKS=' "$tmpdir/output" | cut -d= -f2-)
|
||||
|
||||
echo ""
|
||||
echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
|
||||
echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}"
|
||||
echo ""
|
||||
echo "JWT_KEYS=${JWT_KEYS}"
|
||||
echo ""
|
||||
echo "JWT_JWKS=${JWT_JWKS}"
|
||||
echo ""
|
||||
echo "Ensure the following configuration is uncommented in docker-compose.yml for the asymmetric key pair to work:"
|
||||
echo ""
|
||||
echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}"
|
||||
echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
|
||||
echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
|
||||
echo " Functions: SUPABASE_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
|
||||
echo ""
|
||||
|
||||
if [ "$1" = "--update-env" ]; then
|
||||
update_env=true
|
||||
elif test -t 0; then
|
||||
printf "Update .env file? (y/N) "
|
||||
read -r REPLY
|
||||
case "$REPLY" in
|
||||
[Yy]) update_env=true ;;
|
||||
*) update_env=false ;;
|
||||
esac
|
||||
else
|
||||
echo "Running non-interactively. Pass --update-env to write to .env."
|
||||
update_env=false
|
||||
fi
|
||||
|
||||
if [ "$update_env" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Updating .env..."
|
||||
|
||||
# Append new variables if they don't exist, or update them if they do
|
||||
for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY ANON_KEY_ASYMMETRIC SERVICE_ROLE_KEY_ASYMMETRIC JWT_KEYS JWT_JWKS; do
|
||||
eval "val=\$$var"
|
||||
if grep -q "^${var}=" .env; then
|
||||
sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env
|
||||
else
|
||||
echo "${var}=${val}" >> .env
|
||||
fi
|
||||
done
|
||||
|
||||
# Uncomment new auth configuration in docker-compose.yml
|
||||
echo "Updating docker-compose.yml..."
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
echo "Error: docker-compose.yml not found in $(pwd)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Always fall through to the grep check
|
||||
sed -i.old \
|
||||
-e '/^[ ]*#GOTRUE_JWT_KEYS:/ s/#//' \
|
||||
-e '/^[ ]*#API_JWT_JWKS:/ s/#//' \
|
||||
-e '/^[ ]*#JWT_JWKS:/ s/#//' \
|
||||
-e '/^[ ]*#SUPABASE_JWKS:/ s/#//' \
|
||||
docker-compose.yml || true
|
||||
|
||||
if grep -q '^[ ]*GOTRUE_JWT_KEYS:' docker-compose.yml && \
|
||||
grep -q '^[ ]*API_JWT_JWKS:' docker-compose.yml && \
|
||||
grep -q '^[ ]*JWT_JWKS:' docker-compose.yml && \
|
||||
grep -q '^[ ]*SUPABASE_JWKS:' docker-compose.yml; then
|
||||
echo "Done."
|
||||
else
|
||||
echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually."
|
||||
fi
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Portions of this code are derived from Inder Singh's update-db-pass.sh
|
||||
# Copyright 2025 Inder Singh. Licensed under Apache License 2.0.
|
||||
# Original source:
|
||||
# https://github.com/singh-inder/supabase-automated-self-host/blob/main/docker/update-db-pass.sh
|
||||
#
|
||||
# GitHub discussion here:
|
||||
# https://github.com/supabase/supabase/issues/22605#issuecomment-3323382144
|
||||
#
|
||||
# Changed:
|
||||
# - POSIX shell compatibility
|
||||
# - No hardcoded values for database service and admin user
|
||||
# - Use .env for the admin user and database service port
|
||||
# - Does _not_ set password for supabase_read_only_user (this role is not
|
||||
# supposed to have a password)
|
||||
# - Print all values and confirm before updating
|
||||
# - Stop on any errors
|
||||
#
|
||||
# Heads up:
|
||||
# - Updating _analytics.source_backends is not needed after PR logflare#2069
|
||||
# - Newer Logflare versions use a different table and update connection string
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
if ! docker compose version > /dev/null 2>&1; then
|
||||
echo "Docker Compose not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Missing .env file. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate random hex-only password to avoid issues with SQL/shell
|
||||
new_passwd="$(openssl rand -hex 16)"
|
||||
# If replacing with a custom password, avoid using @/?#:&
|
||||
# https://supabase.com/docs/guides/database/postgres/roles#passwords
|
||||
# new_passwd="d0notUseSpecialSymbolsForPq123-"
|
||||
|
||||
# Check Postgres service
|
||||
db_image_prefix="supabase.postgres:"
|
||||
|
||||
compose_output=$(docker compose ps \
|
||||
--format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null | \
|
||||
grep -m1 "^$db_image_prefix" || true)
|
||||
|
||||
if [ -z "$compose_output" ]; then
|
||||
echo "Postgres container not found. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
db_image=$(echo "$compose_output" | cut -f1)
|
||||
db_srv_name=$(echo "$compose_output" | cut -f2)
|
||||
db_srv_status=$(echo "$compose_output" | cut -f3)
|
||||
|
||||
case "$db_srv_status" in
|
||||
Up*)
|
||||
;;
|
||||
*)
|
||||
echo "Postgres container status: $db_srv_status"
|
||||
echo "Exiting."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
db_srv_port=$(grep "^POSTGRES_PORT=" .env | cut -d '=' -f 2)
|
||||
port_source=" (.env):"
|
||||
if [ -z "$db_srv_port" ]; then
|
||||
db_srv_port="5432"
|
||||
port_source=" (default):"
|
||||
fi
|
||||
|
||||
db_admin_user="supabase_admin"
|
||||
|
||||
echo ""
|
||||
echo "*** Check configuration below before updating database passwords! ***"
|
||||
echo ""
|
||||
echo "Service name: $db_srv_name"
|
||||
echo "Service status: $db_srv_status"
|
||||
echo "Service port${port_source} $db_srv_port"
|
||||
echo "Image: $db_image"
|
||||
echo ""
|
||||
echo "Admin user: $db_admin_user"
|
||||
|
||||
if ! test -t 0; then
|
||||
echo ""
|
||||
echo "Running non-interactively. Not updating passwords."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "New database password: $new_passwd"
|
||||
echo ""
|
||||
|
||||
printf "Update database passwords? (y/N) "
|
||||
read -r REPLY
|
||||
case "$REPLY" in
|
||||
[Yy])
|
||||
;;
|
||||
*)
|
||||
echo "Canceled. Not updating passwords."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Updating passwords..."
|
||||
echo "Connecting to the database service container..."
|
||||
|
||||
docker compose exec -T "$db_srv_name" psql -U "$db_admin_user" -d "_supabase" -v ON_ERROR_STOP=1 <<EOF
|
||||
alter user anon with password '${new_passwd}';
|
||||
alter user authenticated with password '${new_passwd}';
|
||||
alter user authenticator with password '${new_passwd}';
|
||||
alter user dashboard_user with password '${new_passwd}';
|
||||
alter user pgbouncer with password '${new_passwd}';
|
||||
alter user postgres with password '${new_passwd}';
|
||||
alter user service_role with password '${new_passwd}';
|
||||
alter user supabase_admin with password '${new_passwd}';
|
||||
alter user supabase_auth_admin with password '${new_passwd}';
|
||||
alter user supabase_functions_admin with password '${new_passwd}';
|
||||
alter user supabase_replication_admin with password '${new_passwd}';
|
||||
alter user supabase_storage_admin with password '${new_passwd}';
|
||||
|
||||
DROP SCHEMA _supavisor CASCADE;
|
||||
create schema if not exists _supavisor;
|
||||
alter schema _supavisor owner to supabase_admin;
|
||||
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '_analytics'
|
||||
AND table_name = 'source_backends'
|
||||
) THEN
|
||||
UPDATE _analytics.source_backends
|
||||
SET config = jsonb_set(
|
||||
config,
|
||||
'{url}',
|
||||
'"postgresql://${db_admin_user}:${new_passwd}@${db_srv_name}:${db_srv_port}/postgres"',
|
||||
false
|
||||
)
|
||||
WHERE type = 'postgres';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
EOF
|
||||
|
||||
echo "Updating POSTGRES_PASSWORD in .env..."
|
||||
sed -i.old "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=$new_passwd|" .env
|
||||
|
||||
echo ""
|
||||
echo "Success. To update and restart containers use:"
|
||||
echo ""
|
||||
echo "docker compose up -d --force-recreate"
|
||||
echo ""
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Generate secrets and legacy symmetric JWT API keys for self-hosted Supabase.
|
||||
#
|
||||
# Generates: JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, and other secrets
|
||||
# needed for a fresh installation.
|
||||
#
|
||||
# Usage:
|
||||
# sh generate-keys.sh # Interactive: prints keys, prompts to update .env
|
||||
# sh generate-keys.sh --update-env # Prints keys and writes them to .env
|
||||
# sh generate-keys.sh | tee keys # Non-interactive: prints keys only
|
||||
#
|
||||
# Portions of this code are derived from Inder Singh's setup.sh shell script.
|
||||
# Copyright 2025 Inder Singh. Licensed under Apache License 2.0.
|
||||
# Original source: https://github.com/singh-inder/supabase-automated-self-host/blob/main/setup.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
gen_hex() {
|
||||
openssl rand -hex "$1"
|
||||
}
|
||||
|
||||
gen_base64() {
|
||||
openssl rand -base64 "$1"
|
||||
}
|
||||
|
||||
base64_url_encode() {
|
||||
openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
|
||||
}
|
||||
|
||||
gen_token() {
|
||||
payload=$1
|
||||
payload_base64=$(printf %s "$payload" | base64_url_encode)
|
||||
header_base64=$(printf %s "$header" | base64_url_encode)
|
||||
signed_content="${header_base64}.${payload_base64}"
|
||||
signature=$(printf %s "$signed_content" | openssl dgst -binary -sha256 -hmac "$jwt_secret" | base64_url_encode)
|
||||
printf '%s' "${signed_content}.${signature}"
|
||||
}
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "Error: openssl is required but not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jwt_secret="$(gen_base64 30)"
|
||||
|
||||
# Used in gen_token()
|
||||
header='{"alg":"HS256","typ":"JWT"}'
|
||||
iat=$(date +%s)
|
||||
exp=$((iat + 5 * 3600 * 24 * 365)) # 5 years
|
||||
|
||||
# Normalizes JSON formatting so that the token matches https://www.jwt.io/ results
|
||||
anon_payload="{\"role\":\"anon\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}"
|
||||
service_role_payload="{\"role\":\"service_role\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}"
|
||||
|
||||
#echo "anon_payload=$anon_payload"
|
||||
#echo "service_role_payload=$service_role_payload"
|
||||
|
||||
anon_key=$(gen_token "$anon_payload")
|
||||
service_role_key=$(gen_token "$service_role_payload")
|
||||
|
||||
secret_key_base=$(gen_base64 48)
|
||||
realtime_db_enc_key=$(gen_hex 8)
|
||||
vault_enc_key=$(gen_hex 16)
|
||||
pg_meta_crypto_key=$(gen_base64 24)
|
||||
|
||||
logflare_public_access_token=$(gen_base64 24)
|
||||
logflare_private_access_token=$(gen_base64 24)
|
||||
|
||||
s3_protocol_access_key_id=$(gen_hex 16)
|
||||
s3_protocol_access_key_secret=$(gen_hex 32)
|
||||
|
||||
minio_root_password=$(gen_hex 16)
|
||||
|
||||
echo ""
|
||||
echo "JWT_SECRET=${jwt_secret}"
|
||||
echo ""
|
||||
#echo "Issued at: $iat"
|
||||
#echo "Expire: $exp"
|
||||
echo "ANON_KEY=${anon_key}"
|
||||
echo "SERVICE_ROLE_KEY=${service_role_key}"
|
||||
echo ""
|
||||
echo "SECRET_KEY_BASE=${secret_key_base}"
|
||||
echo "REALTIME_DB_ENC_KEY=${realtime_db_enc_key}"
|
||||
echo "VAULT_ENC_KEY=${vault_enc_key}"
|
||||
echo "PG_META_CRYPTO_KEY=${pg_meta_crypto_key}"
|
||||
echo "LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}"
|
||||
echo "LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}"
|
||||
echo "S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}"
|
||||
echo "S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}"
|
||||
echo "MINIO_ROOT_PASSWORD=${minio_root_password}"
|
||||
echo ""
|
||||
|
||||
postgres_password=$(gen_hex 16)
|
||||
dashboard_password=$(gen_hex 16)
|
||||
|
||||
echo "POSTGRES_PASSWORD=${postgres_password}"
|
||||
echo "DASHBOARD_PASSWORD=${dashboard_password}"
|
||||
echo ""
|
||||
|
||||
if [ "$1" = "--update-env" ]; then
|
||||
update_env=true
|
||||
elif test -t 0; then
|
||||
printf "Update .env file? (y/N) "
|
||||
read -r REPLY
|
||||
case "$REPLY" in
|
||||
[Yy]) update_env=true ;;
|
||||
*) update_env=false ;;
|
||||
esac
|
||||
else
|
||||
echo "Running non-interactively. Pass --update-env to write to .env."
|
||||
update_env=false
|
||||
fi
|
||||
|
||||
if [ "$update_env" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Updating .env..."
|
||||
|
||||
sed \
|
||||
-i.old \
|
||||
-e "s|^JWT_SECRET=.*$|JWT_SECRET=${jwt_secret}|" \
|
||||
-e "s|^ANON_KEY=.*$|ANON_KEY=${anon_key}|" \
|
||||
-e "s|^SERVICE_ROLE_KEY=.*$|SERVICE_ROLE_KEY=${service_role_key}|" \
|
||||
-e "s|^SECRET_KEY_BASE=.*$|SECRET_KEY_BASE=${secret_key_base}|" \
|
||||
-e "s|^REALTIME_DB_ENC_KEY=.*$|REALTIME_DB_ENC_KEY=${realtime_db_enc_key}|" \
|
||||
-e "s|^VAULT_ENC_KEY=.*$|VAULT_ENC_KEY=${vault_enc_key}|" \
|
||||
-e "s|^PG_META_CRYPTO_KEY=.*$|PG_META_CRYPTO_KEY=${pg_meta_crypto_key}|" \
|
||||
-e "s|^LOGFLARE_PUBLIC_ACCESS_TOKEN=.*$|LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}|" \
|
||||
-e "s|^LOGFLARE_PRIVATE_ACCESS_TOKEN=.*$|LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}|" \
|
||||
-e "s|^S3_PROTOCOL_ACCESS_KEY_ID=.*$|S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}|" \
|
||||
-e "s|^S3_PROTOCOL_ACCESS_KEY_SECRET=.*$|S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}|" \
|
||||
-e "s|^MINIO_ROOT_PASSWORD=.*$|MINIO_ROOT_PASSWORD=${minio_root_password}|" \
|
||||
-e "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=${postgres_password}|" \
|
||||
-e "s|^DASHBOARD_PASSWORD=.*$|DASHBOARD_PASSWORD=${dashboard_password}|" \
|
||||
.env
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Reassign ownership of public schema objects from supabase_admin to postgres.
|
||||
#
|
||||
# Context and documentation:
|
||||
# https://supabase.com/docs/guides/self-hosting/remove-superuser-access
|
||||
#
|
||||
# Credits:
|
||||
# Original version by Inder Singh.
|
||||
#
|
||||
# Usage:
|
||||
# sh utils/reassign-owner.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "Docker Compose not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Postgres service
|
||||
db_image_prefix="supabase.postgres:"
|
||||
|
||||
compose_output=$(docker compose ps \
|
||||
--format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null |
|
||||
grep -m1 "^$db_image_prefix" || true)
|
||||
|
||||
if [ -z "$compose_output" ]; then
|
||||
echo "Postgres container not found. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
db_srv_name=$(echo "$compose_output" | cut -f2)
|
||||
db_srv_status=$(echo "$compose_output" | cut -f3)
|
||||
|
||||
case "$db_srv_status" in
|
||||
Up*)
|
||||
;;
|
||||
*)
|
||||
echo "Postgres container status: $db_srv_status"
|
||||
echo "Exiting."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! test -t 0; then
|
||||
echo ""
|
||||
echo "Running non-interactively. Not reassigning ownership."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf "Reassign public schema objects to postgres user? (y/N) "
|
||||
read -r REPLY
|
||||
case "$REPLY" in
|
||||
[Yy])
|
||||
;;
|
||||
*)
|
||||
echo "Canceled. Not reassigning ownership."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
docker compose exec -T "$db_srv_name" psql -v ON_ERROR_STOP=1 -U supabase_admin -d postgres <<'EOF'
|
||||
\echo 'Current supabase_admin-owned objects in public schema:'
|
||||
SELECT c.relname, c.relkind, c.relowner::regrole
|
||||
FROM pg_class c
|
||||
WHERE c.relnamespace = 'public'::regnamespace
|
||||
AND c.relowner = 'supabase_admin'::regrole;
|
||||
|
||||
-- Reassign user objects in public schema from supabase_admin to postgres.
|
||||
-- (Only affects public schema; Supabase-managed schemas stay as-is.
|
||||
-- Extension-owned objects are skipped.)
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
rel_count int := 0;
|
||||
fn_count int := 0;
|
||||
type_count int := 0;
|
||||
BEGIN
|
||||
-- Tables, views, sequences, materialized views, partitioned tables
|
||||
FOR rec IN
|
||||
SELECT c.relname, c.relkind
|
||||
FROM pg_class c
|
||||
WHERE c.relnamespace = 'public'::regnamespace
|
||||
AND c.relowner = 'supabase_admin'::regrole
|
||||
AND c.relkind IN ('r', 'v', 'S', 'm', 'p')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_depend d
|
||||
WHERE d.classid = 'pg_class'::regclass
|
||||
AND d.objid = c.oid
|
||||
AND d.deptype = 'e'
|
||||
)
|
||||
ORDER BY CASE c.relkind
|
||||
WHEN 'p' THEN 0 -- partitioned parents first; cascades ownership to partitions
|
||||
WHEN 'm' THEN 1
|
||||
WHEN 'r' THEN 2
|
||||
WHEN 'v' THEN 3
|
||||
WHEN 'S' THEN 4
|
||||
END
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I OWNER TO postgres', rec.relname);
|
||||
rel_count := rel_count + 1;
|
||||
END LOOP;
|
||||
|
||||
-- Functions and procedures
|
||||
FOR rec IN
|
||||
SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args
|
||||
FROM pg_proc p
|
||||
WHERE p.pronamespace = 'public'::regnamespace
|
||||
AND p.proowner = 'supabase_admin'::regrole
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_depend d
|
||||
WHERE d.classid = 'pg_proc'::regclass
|
||||
AND d.objid = p.oid
|
||||
AND d.deptype = 'e'
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('ALTER ROUTINE public.%I(%s) OWNER TO postgres', rec.proname, rec.args);
|
||||
fn_count := fn_count + 1;
|
||||
END LOOP;
|
||||
|
||||
-- Types (excluding array types and table-bound composites)
|
||||
FOR rec IN
|
||||
SELECT t.typname
|
||||
FROM pg_type t
|
||||
WHERE t.typnamespace = 'public'::regnamespace
|
||||
AND t.typowner = 'supabase_admin'::regrole
|
||||
AND t.typrelid = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_type el
|
||||
WHERE el.oid = t.typelem
|
||||
AND el.typarray = t.oid
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_depend d
|
||||
WHERE d.classid = 'pg_type'::regclass
|
||||
AND d.objid = t.oid
|
||||
AND d.deptype = 'e'
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('ALTER TYPE public.%I OWNER TO postgres', rec.typname);
|
||||
type_count := type_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Reassigned % relation(s), % routine(s), % type(s) from supabase_admin to postgres.',
|
||||
rel_count, fn_count, type_count;
|
||||
END
|
||||
$$;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
@@ -1,117 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Rotate opaque API keys for a self-hosted Supabase installation.
|
||||
#
|
||||
# Regenerates SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY
|
||||
# without touching the asymmetric key pair (JWKS) or JWT tokens.
|
||||
#
|
||||
# Usage:
|
||||
# sh rotate-new-api-keys.sh # Interactive: prints keys, prompts to update .env
|
||||
# sh rotate-new-api-keys.sh --update-env # Prints keys and writes them to .env
|
||||
# sh rotate-new-api-keys.sh | tee keys # Non-interactive: prints keys only
|
||||
#
|
||||
# Prerequisites:
|
||||
# - .env file (run generate-keys.sh and add-new-auth-keys.sh first)
|
||||
# - node (>= 16) or docker
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
node_ok() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
[ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null
|
||||
}
|
||||
|
||||
# Resolve how to run node: local install (>= 16) preferred, docker fallback.
|
||||
if node_ok; then
|
||||
node_runner="node"
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
echo "Local node $(node -v) is too old (need >= 16), falling back to docker."
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: requires either node (>= 16) or docker."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Error: docker is installed but the daemon is not running."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker image inspect node:22-alpine >/dev/null 2>&1; then
|
||||
echo "Pulling node:22-alpine (first-run only)..."
|
||||
docker pull node:22-alpine
|
||||
fi
|
||||
|
||||
node_runner="docker run --rm node:22-alpine node"
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Run generate-keys.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
$node_runner -e '
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PROJECT_REF = "supabase-self-hosted";
|
||||
|
||||
function generateOpaqueKey(prefix) {
|
||||
const random = crypto.randomBytes(17).toString("base64url").slice(0, 22);
|
||||
const intermediate = prefix + random;
|
||||
const checksum = crypto.createHash("sha256")
|
||||
.update(PROJECT_REF + "|" + intermediate)
|
||||
.digest("base64url")
|
||||
.slice(0, 8);
|
||||
return intermediate + "_" + checksum;
|
||||
}
|
||||
|
||||
const publishableKey = generateOpaqueKey("sb_publishable_");
|
||||
const secretKey = generateOpaqueKey("sb_secret_");
|
||||
|
||||
console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey);
|
||||
console.log("SUPABASE_SECRET_KEY=" + secretKey);
|
||||
' > "$tmpdir/output"
|
||||
|
||||
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-)
|
||||
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-)
|
||||
|
||||
echo ""
|
||||
echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
|
||||
echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}"
|
||||
echo ""
|
||||
|
||||
if [ "$1" = "--update-env" ]; then
|
||||
update_env=true
|
||||
elif test -t 0; then
|
||||
printf "Update .env file? (y/N) "
|
||||
read -r REPLY
|
||||
case "$REPLY" in
|
||||
[Yy]) update_env=true ;;
|
||||
*) update_env=false ;;
|
||||
esac
|
||||
else
|
||||
echo "Running non-interactively. Pass --update-env to write to .env."
|
||||
update_env=false
|
||||
fi
|
||||
|
||||
if [ "$update_env" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Updating .env..."
|
||||
|
||||
for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY; do
|
||||
eval "val=\$$var"
|
||||
if grep -q "^${var}=" .env; then
|
||||
sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env
|
||||
else
|
||||
echo "${var}=${val}" >> .env
|
||||
fi
|
||||
done
|
||||
@@ -1,822 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Requires bash (not sh) for pipefail, which ensures failures in piped
|
||||
# commands are caught during the upgrade.
|
||||
#
|
||||
# Upgrade self-hosted Supabase Postgres from 15 to 17.
|
||||
#
|
||||
# Uses Supabase's pg_upgrade scripts (initiate.sh + complete.sh) inside a
|
||||
# temporary PG 15 container, then swaps data directories and starts Postgres 17.
|
||||
#
|
||||
# Usage (must be run as root or with sudo):
|
||||
# cd docker/
|
||||
# sudo bash utils/upgrade-pg17.sh # Interactive (prompts for confirmation)
|
||||
# sudo bash utils/upgrade-pg17.sh --yes # Non-interactive (skip all prompts)
|
||||
#
|
||||
# Requirements:
|
||||
# - Docker with Docker Compose (docker compose, not docker-compose)
|
||||
# - Running Supabase self-hosted setup with Postgres 15
|
||||
# - At least 2x current database size + 5 GB free disk space
|
||||
#
|
||||
# Backup:
|
||||
# The original Postgres 15 data directory is preserved as
|
||||
# ./volumes/db/data.bak.pg15 during the upgrade.
|
||||
# DO NOT DELETE it until you have verified the upgrade was successful.
|
||||
#
|
||||
# Rollback (if the upgrade fails or you want to revert):
|
||||
# 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down
|
||||
# 2. rm -rf ./volumes/db/data
|
||||
# 3. mv ./volumes/db/data.bak.pg15 ./volumes/db/data
|
||||
# 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/
|
||||
# 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
|
||||
#
|
||||
|
||||
# Ensure we're running under bash (not sh/zsh/dash).
|
||||
# Check that $BASH ends with /bash (not /sh, /zsh, etc.).
|
||||
case "${BASH:-}" in
|
||||
*/bash) ;;
|
||||
*) echo "Error: This script requires bash. Run it with: sudo bash $0" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AUTO_CONFIRM=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) AUTO_CONFIRM=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Configuration ----------------------------------------------------------
|
||||
|
||||
# Image used for the upgrade tarball + complete.sh container.
|
||||
# Must share glibc with PG 15 (the extracted ELF binaries run inside PG15).
|
||||
# Pinned to .063: later images bumped glibc, which breaks the ELF extraction.
|
||||
PG17_UPGRADE_IMAGE="supabase/postgres:17.6.1.063"
|
||||
# Tag in supabase/postgres repo matching the upgrade image (for downloading scripts)
|
||||
PG17_SCRIPTS_REF="17.6.1.063"
|
||||
|
||||
# Final Postgres 17 image that runs after the upgrade. Pinned here (not read from
|
||||
# the compose files). Keep in sync with the db image in docker-compose.yml (and
|
||||
# docker-compose.pg17.yml). Used for pulling, chowning data/db-config to the
|
||||
# target's postgres UID, and running the post-upgrade migrations.
|
||||
PG17_TARGET_IMAGE="supabase/postgres:17.6.1.136"
|
||||
|
||||
DB_CONTAINER="supabase-db"
|
||||
UPGRADE_CONTAINER="supabase-pg-upgrade"
|
||||
COMPLETE_CONTAINER="supabase-pg-complete"
|
||||
|
||||
DATA_DIR="./volumes/db/data"
|
||||
BACKUP_DIR="./volumes/db/data.bak.pg15"
|
||||
# Include image tag in cache filename so changing PG17_UPGRADE_IMAGE invalidates it
|
||||
PG17_TAG="${PG17_UPGRADE_IMAGE##*:}"
|
||||
TARBALL_CACHE="./volumes/db/pg17_upgrade_bin_${PG17_TAG}.tar.gz"
|
||||
# initiate.sh writes pg_upgrade output here: pgdata/, conf/, sql/
|
||||
MIGRATION_DIR="./volumes/db/data_migration"
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
|
||||
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
|
||||
info() { printf '\n==> %s\n' "$*"; }
|
||||
warn() { printf 'Warning: %s\n' "$*" >&2; }
|
||||
|
||||
# Temp dir on host for tarball + scripts (mounted into containers)
|
||||
staging_dir=""
|
||||
pg_password=""
|
||||
current_image=""
|
||||
drop_extensions=""
|
||||
db_config_vol=""
|
||||
|
||||
# Remove leftover containers and staging dir on exit.
|
||||
# Uses an alpine container for rm because the tarball build runs as root
|
||||
# inside Docker - the resulting files are root-owned and can't be deleted
|
||||
# by the host user on macOS.
|
||||
cleanup() {
|
||||
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
|
||||
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
|
||||
if [ -n "$staging_dir" ] && [ -d "$staging_dir" ]; then
|
||||
docker run --rm -v "$staging_dir:/cleanup" alpine rm -rf /cleanup 2>/dev/null || true
|
||||
rm -rf "$staging_dir" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
on_interrupt() {
|
||||
echo ""
|
||||
warn "Interrupted. Cleaning up..."
|
||||
# If db-config was chowned to PG17, restore for PG 15 rollback
|
||||
if [ -n "$db_config_vol" ] && [ -n "$current_image" ]; then
|
||||
docker run --rm -v "${db_config_vol}:/vol" "$current_image" \
|
||||
chown -R postgres:postgres /vol/ 2>/dev/null || true
|
||||
fi
|
||||
die "Interrupted."
|
||||
}
|
||||
trap on_interrupt INT
|
||||
|
||||
confirm() {
|
||||
if [ "$AUTO_CONFIRM" = true ]; then return 0; fi
|
||||
if ! test -t 0; then
|
||||
die "This script must be run interactively, or use --yes to skip prompts."
|
||||
fi
|
||||
printf '%s (y/N) ' "$1"
|
||||
read -r reply
|
||||
case "$reply" in
|
||||
[Yy]*) return 0 ;;
|
||||
*) echo "Aborted."; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_sql_on() {
|
||||
local container=$1; shift
|
||||
docker exec -i \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
"$container" \
|
||||
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
|
||||
}
|
||||
|
||||
wait_for_healthy() {
|
||||
local container=$1 retries=30
|
||||
while [ $retries -gt 0 ]; do
|
||||
if docker exec "$container" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
retries=$((retries - 1))
|
||||
sleep 1
|
||||
done
|
||||
die "Postgres in '$container' did not become ready in 30 seconds."
|
||||
}
|
||||
|
||||
# --- Pre-flight checks -----------------------------------------------------
|
||||
|
||||
preflight() {
|
||||
info "Running pre-flight checks"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
die "This script must be run as root (e.g. sudo bash $0)."
|
||||
fi
|
||||
|
||||
docker compose version >/dev/null 2>&1 || die "Docker Compose not found."
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required (for downloading upgrade scripts)."
|
||||
[ -f docker-compose.yml ] || die "Run this script from the docker/ directory."
|
||||
[ -f docker-compose.pg17.yml ] || die "Missing docker-compose.pg17.yml."
|
||||
[ -f .env ] || die "Missing .env file."
|
||||
|
||||
# Resolve db-config volume (exact match on _db-config suffix or bare db-config)
|
||||
db_config_vol=$(docker volume ls --filter "name=db-config" --format '{{.Name}}' \
|
||||
| grep -E '^db-config$|_db-config$' | head -n 1)
|
||||
[ -n "$db_config_vol" ] || die "Could not find db-config volume. Is Supabase running?"
|
||||
|
||||
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2- | sed "s/^['\"]//;s/['\"]$//" | head -n 1)
|
||||
[ -n "$pg_password" ] || die "POSTGRES_PASSWORD not set in .env."
|
||||
|
||||
docker inspect "$DB_CONTAINER" >/dev/null 2>&1 \
|
||||
|| die "Container '$DB_CONTAINER' not found. Is Supabase running?"
|
||||
|
||||
current_image=$(docker inspect "$DB_CONTAINER" --format '{{.Config.Image}}')
|
||||
case "$current_image" in
|
||||
supabase/postgres:15.*|supabase.postgres:15.*) ;;
|
||||
supabase/postgres:17.*|supabase.postgres:17.*) die "Already running Postgres 17 ($current_image)." ;;
|
||||
*) die "Unexpected database image: $current_image" ;;
|
||||
esac
|
||||
|
||||
local status
|
||||
status=$(docker inspect "$DB_CONTAINER" --format '{{.State.Status}}')
|
||||
[ "$status" = "running" ] || die "'$DB_CONTAINER' is not running (status: $status)."
|
||||
[ -d "$DATA_DIR" ] || die "Data directory not found: $DATA_DIR"
|
||||
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
warn "Backup directory already exists: $BACKUP_DIR"
|
||||
warn "This is likely from a previous upgrade attempt."
|
||||
warn "If you haven't verified that previous upgrade, roll back first:"
|
||||
warn " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
|
||||
warn " 2. rm -rf $DATA_DIR"
|
||||
warn " 3. mv $BACKUP_DIR $DATA_DIR"
|
||||
warn " 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
|
||||
warn " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
|
||||
echo ""
|
||||
warn "Continuing will DELETE the existing backup permanently."
|
||||
confirm "Delete $BACKUP_DIR and start a fresh upgrade?"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
fi
|
||||
if [ -d "$MIGRATION_DIR" ]; then
|
||||
rm -rf "$MIGRATION_DIR"
|
||||
fi
|
||||
|
||||
# Disk space
|
||||
local data_size_kb data_size_mb avail_kb avail_mb needed_mb
|
||||
data_size_kb=$(du -sk "$DATA_DIR" 2>/dev/null | cut -f1)
|
||||
[ -n "$data_size_kb" ] || die "Could not calculate data size for $DATA_DIR"
|
||||
data_size_mb=$((data_size_kb / 1024))
|
||||
avail_kb=$(df -k "$(dirname "$DATA_DIR")" | awk 'NR==2 { print $4 }')
|
||||
[ -n "$avail_kb" ] || die "Could not calculate available disk space for $(dirname "$DATA_DIR")"
|
||||
avail_mb=$((avail_kb / 1024))
|
||||
needed_mb=$((data_size_mb * 2 + 5000))
|
||||
echo " Data size: ${data_size_mb} MB"
|
||||
echo " Available space: ${avail_mb} MB"
|
||||
echo " Estimated need: ${needed_mb} MB"
|
||||
if [ "$avail_mb" -lt "$needed_mb" ]; then
|
||||
warn "Disk space may be insufficient."
|
||||
warn "pg_upgrade copies data; need ~2x data size + ~5 GB for the upgrade tarball."
|
||||
confirm "Continue anyway?"
|
||||
fi
|
||||
|
||||
# Incompatible extensions
|
||||
info "Checking for incompatible extensions"
|
||||
local incompatible
|
||||
incompatible=$(run_sql_on "$DB_CONTAINER" -A -t -c "
|
||||
SELECT string_agg(extname, ', ')
|
||||
FROM pg_extension
|
||||
WHERE extname IN ('timescaledb', 'plv8', 'plcoffee', 'plls');
|
||||
" 2>/dev/null | tr -d '[:space:]') || true
|
||||
|
||||
if [ -n "$incompatible" ]; then
|
||||
warn "Incompatible extensions found: $incompatible"
|
||||
warn "These do not exist in Postgres 17 and must be dropped before upgrading."
|
||||
warn "If you proceed, they will be dropped automatically."
|
||||
warn "The original data is preserved as a backup so you can roll back."
|
||||
confirm "Drop these extensions and continue with the upgrade?"
|
||||
drop_extensions="$incompatible"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "This script will:"
|
||||
echo " 1. Pull the Postgres 17 image"
|
||||
echo " 2. Build an upgrade tarball from the image (~1.2 GB compressed, temporary)"
|
||||
echo " 3. Stop all Supabase services"
|
||||
echo " 4. Run pg_upgrade (Postgres 15 -> 17)"
|
||||
echo " 5. Apply post-upgrade patches"
|
||||
echo " 6. Start Supabase with Postgres 17"
|
||||
echo " 7. Apply additional migrations"
|
||||
echo ""
|
||||
echo " Current image: $current_image"
|
||||
echo " Target image: $PG17_TARGET_IMAGE"
|
||||
echo " Upgrade image: $PG17_UPGRADE_IMAGE"
|
||||
echo " Data directory: $DATA_DIR"
|
||||
echo " Backup location: $BACKUP_DIR"
|
||||
echo ""
|
||||
confirm "Proceed with the upgrade?"
|
||||
}
|
||||
|
||||
# --- Step 1: Pull Postgres 17 image ----------------------------------------
|
||||
|
||||
pull_image() {
|
||||
info "Pulling Postgres 17 images"
|
||||
docker pull "$PG17_UPGRADE_IMAGE"
|
||||
if [ "$PG17_TARGET_IMAGE" != "$PG17_UPGRADE_IMAGE" ]; then
|
||||
docker pull "$PG17_TARGET_IMAGE"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Step 2: Build upgrade tarball -----------------------------------------
|
||||
#
|
||||
# Extracts PG 17 binaries, libraries, share data, and upgrade scripts from
|
||||
# the PG 17 Docker image into a tarball that initiate.sh can consume.
|
||||
#
|
||||
# The tarball uses the "non-nix" layout (17/bin, 17/lib, 17/share - no
|
||||
# nix_flake_version file), so initiate.sh sets LD_LIBRARY_PATH to find
|
||||
# the bundled libraries.
|
||||
|
||||
build_tarball() {
|
||||
local tmpbase="${TMPDIR:-/tmp}"
|
||||
staging_dir=$(mktemp -d "${tmpbase%/}/supabase-pg17-upgrade.XXXXXX")
|
||||
# World-writable so Docker containers can write to bind mounts on macOS,
|
||||
# where the VM's root user has no special access to host directories.
|
||||
chmod 777 "$staging_dir"
|
||||
echo " Staging directory: $staging_dir"
|
||||
|
||||
# Download upgrade scripts from the supabase/postgres repo (pinned to PG17_SCRIPTS_REF).
|
||||
# These are no longer bundled in the latest PG 17 Docker images.
|
||||
info "Downloading upgrade scripts (ref: $PG17_SCRIPTS_REF)"
|
||||
local scripts_base="https://raw.githubusercontent.com/supabase/postgres/${PG17_SCRIPTS_REF}/ansible/files/admin_api_scripts/pg_upgrade_scripts"
|
||||
mkdir -p "$staging_dir/scripts"
|
||||
for script in initiate.sh complete.sh common.sh pgsodium_getkey.sh check.sh prepare.sh; do
|
||||
curl -fsSL "$scripts_base/$script" -o "$staging_dir/scripts/$script" \
|
||||
|| die "Failed to download $script from GitHub"
|
||||
done
|
||||
|
||||
if [ -f "$TARBALL_CACHE" ]; then
|
||||
info "Using cached upgrade tarball: $TARBALL_CACHE"
|
||||
cp "$TARBALL_CACHE" "$staging_dir/pg_upgrade_bin.tar.gz"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Building upgrade tarball from Postgres 17 image (first run)"
|
||||
docker run --rm --user root --entrypoint bash \
|
||||
-v "$staging_dir:/export" \
|
||||
"$PG17_UPGRADE_IMAGE" \
|
||||
-c '
|
||||
set -euo pipefail
|
||||
mkdir -p /export/17/bin /export/17/lib /export/17/share
|
||||
|
||||
echo " Copying binaries..."
|
||||
# Binaries in the nix profile are either ELF binaries or shell
|
||||
# wrappers that exec a .xxx-wrapped ELF from the nix store.
|
||||
# Extract the actual ELF binaries so they work outside nix.
|
||||
BIN_DIR=$(dirname $(readlink -f /usr/lib/postgresql/bin/postgres))
|
||||
for f in "$BIN_DIR"/*; do
|
||||
name=$(basename "$f")
|
||||
|
||||
# Skip nix wrapper-internal files
|
||||
case "$name" in .*-wrapped) continue ;; esac
|
||||
|
||||
# Check for ELF
|
||||
if [ -x "$f" ] && file -b "$f" | grep -q "ELF .* executable"; then
|
||||
cp "$f" /export/17/bin/"$name"
|
||||
else
|
||||
# Shell wrapper - extract the real .xxx-wrapped ELF path
|
||||
wrapped=$(grep -o "/nix/store/[^ \"]*-wrapped" "$f" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$wrapped" ] && [ -f "$wrapped" ]; then
|
||||
cp "$wrapped" /export/17/bin/"$name"
|
||||
else
|
||||
cp "$f" /export/17/bin/"$name"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo " Copying libraries..."
|
||||
PKGLIBDIR=$(pg_config --pkglibdir)
|
||||
LIBDIR=$(pg_config --libdir)
|
||||
|
||||
# These paths may overlap (PKGLIBDIR and LIBDIR often point to the
|
||||
# same nix store path). Use cp -Lf to handle overwrites from
|
||||
# read-only nix store source files.
|
||||
cp -Lf "$PKGLIBDIR"/*.so /export/17/lib/ || echo " Warning: cp from $PKGLIBDIR failed" >&2
|
||||
cp -Lf "$LIBDIR"/*.so* /export/17/lib/ || echo " Warning: cp from $LIBDIR failed" >&2
|
||||
cp -Lf /nix/var/nix/profiles/default/lib/*.so* /export/17/lib/ || echo " Warning: cp from nix profile lib failed" >&2
|
||||
|
||||
echo " Copying share data..."
|
||||
|
||||
# Nix-built binaries resolve share dir relative to their location:
|
||||
# <bindir>/../share/postgresql/
|
||||
# so we need share/postgresql/ not just share/
|
||||
mkdir -p /export/17/share/postgresql
|
||||
|
||||
# Remove cyclic symlink (timezonesets/timezonesets -> timezonesets).
|
||||
rm -f /usr/share/postgresql/timezonesets/timezonesets 2>/dev/null || true
|
||||
|
||||
# Pre-create subdirectories so cp -rL does not need to mkdir them.
|
||||
# On macOS with Docker Desktop bind mount rejects mkdir with the nix store
|
||||
# read-only (dr-xr-xr-x) permissions; pre-creating with default
|
||||
# writable permissions fixed this
|
||||
mkdir -p /export/17/share/postgresql/{extension,timezonesets,tsearch_data}
|
||||
mkdir -p /export/17/share/postgresql/extension/{functions,procedures,tables,types}
|
||||
|
||||
cp -rL /usr/share/postgresql/* /export/17/share/postgresql/ || echo " Warning: cp share data had errors" >&2
|
||||
|
||||
# initiate.sh copies .control/.sql from PGLIBNEW to PGSHARENEW/extension/
|
||||
echo " Copying extension definitions to lib..."
|
||||
SHAREDIR=$(pg_config --sharedir)
|
||||
cp "$SHAREDIR"/extension/*.control /export/17/lib/ || echo " Warning: cp .control from $SHAREDIR/extension failed" >&2
|
||||
cp "$SHAREDIR"/extension/*.sql /export/17/lib/ || echo " Warning: cp .sql from $SHAREDIR/extension failed" >&2
|
||||
|
||||
# Verify critical files before creating tarball
|
||||
echo " Checking for key files..."
|
||||
[ -f /export/17/bin/postgres ] || { echo "Error: bin/postgres missing"; exit 1; }
|
||||
[ -f /export/17/share/postgresql/timezonesets/Default ] || { echo "Error: timezonesets/Default missing"; exit 1; }
|
||||
ls /export/17/share/postgresql/extension/*.control >/dev/null 2>&1 || { echo "Error: no .control files in extension/"; exit 1; }
|
||||
ls /export/17/lib/*.so >/dev/null 2>&1 || { echo "Error: no .so files in lib/"; exit 1; }
|
||||
|
||||
echo " Creating tarball (this may take several minutes)..."
|
||||
cd /export && tar czf pg_upgrade_bin.tar.gz 17/
|
||||
|
||||
echo " Tarball: $(du -sh /export/pg_upgrade_bin.tar.gz | cut -f1)"
|
||||
'
|
||||
|
||||
# Cache for next run
|
||||
cp "$staging_dir/pg_upgrade_bin.tar.gz" "$TARBALL_CACHE"
|
||||
info "Tarball cached at $TARBALL_CACHE"
|
||||
}
|
||||
|
||||
# --- Step 3: Drop incompatible extensions ----------------------------------
|
||||
|
||||
drop_incompatible_extensions() {
|
||||
if [ -z "$drop_extensions" ]; then
|
||||
return
|
||||
fi
|
||||
info "Dropping incompatible extensions"
|
||||
|
||||
local ext
|
||||
echo "$drop_extensions" | tr ',' '\n' | while read -r ext; do
|
||||
ext=$(echo "$ext" | tr -d '[:space:]')
|
||||
[ -z "$ext" ] && continue
|
||||
echo " DROP EXTENSION $ext CASCADE"
|
||||
run_sql_on "$DB_CONTAINER" -c "DROP EXTENSION IF EXISTS \"$ext\" CASCADE;"
|
||||
done
|
||||
}
|
||||
|
||||
# --- Step 4: Stop services and back up -------------------------------------
|
||||
|
||||
stop_and_backup() {
|
||||
info "Backing up pgsodium root key"
|
||||
local key_backup="./volumes/db/pgsodium_root.key.bak.pg15"
|
||||
docker run --rm -v "${db_config_vol}:/src:ro" -v "$(pwd)/volumes/db:/dst" \
|
||||
alpine cp /src/pgsodium_root.key /dst/pgsodium_root.key.bak.pg15 \
|
||||
|| die "Failed to back up pgsodium root key from db-config volume."
|
||||
echo " Saved to: $key_backup"
|
||||
|
||||
info "Stopping all Supabase services"
|
||||
docker compose down
|
||||
|
||||
echo " Original data will be preserved as: $BACKUP_DIR"
|
||||
}
|
||||
|
||||
# --- Step 5: Run pg_upgrade via initiate.sh + complete.sh ------------------
|
||||
#
|
||||
# Host directories are mounted at non-standard paths (/mnt/host-*) with
|
||||
# symlinks at the paths the upgrade scripts expect. This lets complete.sh's
|
||||
# CI wrapper (which does rm/mv/ln on /var/lib/postgresql/data and
|
||||
# /data_migration) operate on symlinks rather than bind mounts.
|
||||
|
||||
run_upgrade() {
|
||||
local abs_data_dir abs_migration_dir
|
||||
|
||||
mkdir -p "$MIGRATION_DIR"
|
||||
# World-writable for macOS Docker bind mount compatibility (see build_tarball)
|
||||
chmod 777 "$MIGRATION_DIR"
|
||||
abs_data_dir=$(cd "$DATA_DIR" && pwd)
|
||||
abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
|
||||
|
||||
info "Starting upgrade container"
|
||||
docker run -d --name "$UPGRADE_CONTAINER" \
|
||||
--entrypoint sleep \
|
||||
-v "${abs_data_dir}:/mnt/host-pgdata" \
|
||||
-v "${abs_migration_dir}:/mnt/host-migration" \
|
||||
-v "${db_config_vol}:/etc/postgresql-custom" \
|
||||
-v "${staging_dir}:/tmp/staging:ro" \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
"$current_image" \
|
||||
infinity
|
||||
|
||||
info "Preparing upgrade environment"
|
||||
docker exec "$UPGRADE_CONTAINER" bash -c '
|
||||
# Symlink bind mounts to the paths the upgrade scripts expect
|
||||
rm -rf /var/lib/postgresql/data
|
||||
ln -s /mnt/host-pgdata /var/lib/postgresql/data
|
||||
ln -s /mnt/host-migration /data_migration
|
||||
|
||||
mkdir -p /tmp/persistent /tmp/upgrade /tmp/pg_upgrade
|
||||
cp /tmp/staging/pg_upgrade_bin.tar.gz /tmp/persistent/
|
||||
cp /tmp/staging/scripts/*.sh /tmp/upgrade/
|
||||
chmod +x /tmp/upgrade/*.sh
|
||||
|
||||
# Patch CI_start_postgres to use "restart" instead of "start" so it
|
||||
# is idempotent (initiate.sh starts postgres for top-level queries,
|
||||
# then handle_extensions calls CI_start_postgres again)
|
||||
sed -i "s/pg_ctl start -o/pg_ctl restart -o/g" /tmp/upgrade/common.sh
|
||||
|
||||
# Patch PGSHARENEW to match nix binary expectations (share/postgresql/)
|
||||
sed -i "s|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share\"|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share/postgresql\"|" /tmp/upgrade/initiate.sh
|
||||
'
|
||||
|
||||
info "Starting Postgres 15 in upgrade container"
|
||||
docker exec "$UPGRADE_CONTAINER" bash -c '
|
||||
su postgres -c "pg_ctl start -o \"-c config_file=/etc/postgresql/postgresql.conf\" -l /tmp/postgres.log"
|
||||
'
|
||||
wait_for_healthy "$UPGRADE_CONTAINER"
|
||||
|
||||
# initiate.sh expects the PG 17 binaries tarball at /tmp/persistent/pg_upgrade_bin.tar.gz
|
||||
# (hardcoded path - copied there during container setup above).
|
||||
#
|
||||
# Env vars for the unwrapped nix ELF binaries in the tarball:
|
||||
# LD_LIBRARY_PATH - find libpq, libssl, etc. (RUNPATH points to absent nix store paths)
|
||||
# NIX_PGLIBDIR - postgres uses this to find extension .so files
|
||||
|
||||
info "Running initiate.sh (pg_upgrade: Postgres 15 -> 17)"
|
||||
echo " This may take several minutes depending on database size..."
|
||||
echo ""
|
||||
if ! docker exec \
|
||||
-e IS_CI=true \
|
||||
-e PG_MAJOR_VERSION=17 \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
-e LD_LIBRARY_PATH=/tmp/pg_upgrade_bin/17/lib \
|
||||
-e NIX_PGLIBDIR=/tmp/pg_upgrade_bin/17/lib \
|
||||
"$UPGRADE_CONTAINER" \
|
||||
/tmp/upgrade/initiate.sh 17; then
|
||||
echo ""
|
||||
warn "initiate.sh failed. Its cleanup may have restored the original state"
|
||||
warn "(re-enabled extensions, revoked superuser). Your data directory is"
|
||||
warn "unchanged - no data was moved or deleted."
|
||||
warn ""
|
||||
warn "Check the output above for the root cause, fix it, and re-run."
|
||||
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
|
||||
die "initiate.sh failed"
|
||||
fi
|
||||
|
||||
info "initiate.sh completed successfully"
|
||||
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# --- Step 6: Run complete.sh in a native PG 17 container -------------------
|
||||
#
|
||||
# complete.sh applies post-upgrade patches (pg_net grants, vault re-encryption,
|
||||
# pg_cron, predefined roles, vacuumdb, etc.). We run it in a PG17 container
|
||||
# where the binaries are native - no nix extraction or LD_LIBRARY_PATH needed.
|
||||
|
||||
run_complete() {
|
||||
local abs_migration_dir
|
||||
|
||||
abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
|
||||
|
||||
info "Starting PG17 container for complete.sh"
|
||||
docker run -d --name "$COMPLETE_CONTAINER" \
|
||||
--entrypoint sleep \
|
||||
-v "${abs_migration_dir}:/mnt/host-migration" \
|
||||
-v "${db_config_vol}:/etc/postgresql-custom" \
|
||||
-v "${staging_dir}:/tmp/staging:ro" \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
"$PG17_UPGRADE_IMAGE" \
|
||||
infinity
|
||||
|
||||
info "Preparing complete.sh environment"
|
||||
# Save original db-config ownership so we can restore it if complete.sh fails.
|
||||
# complete.sh needs PG 17 ownership to start postgres, but if it fails the
|
||||
# user needs to fall back to PG 15 which uses a different uid.
|
||||
docker exec "$COMPLETE_CONTAINER" bash -c '
|
||||
stat -c "%u:%g" /etc/postgresql-custom/pgsodium_root.key 2>/dev/null > /tmp/dbconfig_owner || true
|
||||
'
|
||||
|
||||
docker exec "$COMPLETE_CONTAINER" bash -c '
|
||||
# Symlink bind mount so complete.sh CI wrapper can mv/rm/ln
|
||||
ln -s /mnt/host-migration /data_migration
|
||||
|
||||
# Remove the image default data dir (complete.sh creates a symlink here)
|
||||
rm -rf /var/lib/postgresql/data
|
||||
|
||||
# Fix ownership on db-config volume (PG15 uid differs from PG17)
|
||||
chown -R postgres:postgres /etc/postgresql-custom/
|
||||
|
||||
# PG17 config includes this directory; may not exist from PG15
|
||||
mkdir -p /etc/postgresql-custom/conf.d
|
||||
|
||||
mkdir -p /tmp/upgrade
|
||||
|
||||
# Copy upgrade scripts
|
||||
cp /tmp/staging/scripts/*.sh /tmp/upgrade/
|
||||
chmod +x /tmp/upgrade/*.sh
|
||||
|
||||
# Patch --new-bin to use native bindir (we are in a PG17 container,
|
||||
# no need for /tmp/pg_upgrade_bin/ paths)
|
||||
sed -i "s|BINDIR=\"/tmp/pg_upgrade_bin/\$PG_MAJOR_VERSION/bin\"|BINDIR=\$(pg_config --bindir)|g" /tmp/upgrade/common.sh
|
||||
'
|
||||
|
||||
info "Running complete.sh (post-upgrade patches, vacuum analyze)"
|
||||
docker exec \
|
||||
-e IS_CI=true \
|
||||
-e PG_MAJOR_VERSION=17 \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
"$COMPLETE_CONTAINER" \
|
||||
/tmp/upgrade/complete.sh || true
|
||||
|
||||
# complete.sh's ERR trap exits with 0 in some cases; check status file
|
||||
local status
|
||||
status=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/pg-upgrade-status 2>/dev/null || echo "unknown")
|
||||
if [ "$status" != "complete" ]; then
|
||||
warn "complete.sh failed. Postgres log:"
|
||||
docker exec "$COMPLETE_CONTAINER" cat /tmp/postgres.log 2>/dev/null || true
|
||||
echo ""
|
||||
# Restore db-config ownership so PG 15 can start for rollback
|
||||
warn "Restoring db-config ownership for PG15..."
|
||||
local orig_owner
|
||||
orig_owner=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/dbconfig_owner 2>/dev/null || true)
|
||||
if [ -n "$orig_owner" ]; then
|
||||
docker exec "$COMPLETE_CONTAINER" chown -R "$orig_owner" /etc/postgresql-custom/ 2>/dev/null || true
|
||||
fi
|
||||
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
|
||||
echo ""
|
||||
echo " Your Postgres 15 data is unchanged (data swap has not happened yet)."
|
||||
echo " To restart Postgres 15:"
|
||||
echo " rm -rf $MIGRATION_DIR"
|
||||
echo " docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
|
||||
echo ""
|
||||
die "complete.sh failed (status: $status)"
|
||||
fi
|
||||
|
||||
info "complete.sh finished successfully"
|
||||
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# --- Step 7: Swap data directories -----------------------------------------
|
||||
|
||||
swap_data() {
|
||||
info "Swapping data directories"
|
||||
|
||||
echo " $DATA_DIR -> $BACKUP_DIR"
|
||||
mv "$DATA_DIR" "$BACKUP_DIR"
|
||||
|
||||
echo " $MIGRATION_DIR/pgdata -> $DATA_DIR"
|
||||
mv "$MIGRATION_DIR/pgdata" "$DATA_DIR"
|
||||
rm -rf "$MIGRATION_DIR"
|
||||
}
|
||||
|
||||
# --- Step 8: Start Postgres 17 ---------------------------------------------
|
||||
|
||||
start_pg17() {
|
||||
info "Starting Supabase with Postgres 17"
|
||||
|
||||
# Ensure db-config volume has correct ownership and structure for PG 17.
|
||||
# complete.sh does this too, but just in case of partial
|
||||
# failures from previous runs.
|
||||
docker run --rm -v "${db_config_vol}:/vol" "$PG17_TARGET_IMAGE" sh -c '
|
||||
mkdir -p /vol/conf.d
|
||||
chown -R postgres:postgres /vol/
|
||||
'
|
||||
|
||||
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
|
||||
|
||||
echo " Waiting for Postgres 17 to be ready..."
|
||||
local retries=60
|
||||
while [ $retries -gt 0 ]; do
|
||||
if docker exec "$DB_CONTAINER" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
retries=$((retries - 1))
|
||||
sleep 2
|
||||
done
|
||||
[ $retries -gt 0 ] || die "Postgres 17 did not start within 120 seconds."
|
||||
|
||||
local new_version
|
||||
new_version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SHOW server_version;" 2>/dev/null | head -n 1)
|
||||
echo " Postgres version: $new_version"
|
||||
case "$new_version" in
|
||||
17.*) ;;
|
||||
*) die "Expected Postgres 17.x, got: $new_version" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- Step 9: Apply migrations not covered by complete.sh -------------------
|
||||
#
|
||||
# These PG 17 migrations run on fresh installs via initdb but not after
|
||||
# pg_upgrade (init scripts don't rerun when PG_VERSION already exists).
|
||||
# complete.sh doesn't cover them either. On the platform a tracked migration
|
||||
# runner (dbmate) applies them; self-hosted has no such tracking, so we apply
|
||||
# the idempotent, safe-on-an-existing-DB ones directly here.
|
||||
#
|
||||
# Source: postgres/migrations/db/migrations/
|
||||
# - 20250710151649_supabase_read_only_user_default_transaction_read_only.sql
|
||||
# - 20251001204436_predefined_role_grants.sql (supabase_etl_admin + pg_monitor)
|
||||
# - 20251105172723_grant_pg_reload_conf_to_postgres.sql
|
||||
# - 20251121132723_correct_search_path_pgbouncer.sql
|
||||
# - 20260211120934_supabase_privileged_role.sql (target image's supautils.conf
|
||||
# references this role; it must exist or supautils config is broken)
|
||||
# - 20260413000000_fix-authenticator-session-preload-libraries.sql
|
||||
# - 20260421000001_rescope_pg_graphql_access_trigger.sql
|
||||
#
|
||||
# NOT applied:
|
||||
# - 20260421000000_pg_graphql-off-by-default.sql: drops pg_graphql. Safe on a
|
||||
# fresh install, but destructive on an existing DB that uses pg_graphql.
|
||||
#
|
||||
# This step also reconciles extension versions (complete.sh ran with the .063
|
||||
# binaries) and the pg_cron 1.6 -> 1.6.4 version label. See below.
|
||||
|
||||
apply_role_migrations() {
|
||||
info "Applying Postgres 17 migrations"
|
||||
|
||||
# Fix collation version mismatch first (upgrade used glibc 2.39, target
|
||||
# image may use glibc 2.40). Do this before any other SQL to suppress
|
||||
# the noisy warnings on every subsequent command.
|
||||
for db in postgres template1 _supabase; do
|
||||
docker exec -i -e PGPASSWORD="$pg_password" "$DB_CONTAINER" \
|
||||
psql -h localhost -U supabase_admin -d "$db" \
|
||||
-c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION;" || true
|
||||
done
|
||||
|
||||
# Create supabase_etl_admin role (doesn't exist in PG 15 images).
|
||||
# Must be created before running predefined_role_grants.sql which
|
||||
# assumes it exists.
|
||||
run_sql_on "$DB_CONTAINER" -c "
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_etl_admin') THEN
|
||||
CREATE USER supabase_etl_admin WITH LOGIN REPLICATION;
|
||||
GRANT pg_read_all_data TO supabase_etl_admin;
|
||||
GRANT CREATE ON DATABASE postgres TO supabase_etl_admin;
|
||||
END IF;
|
||||
END
|
||||
\$\$;" || true
|
||||
|
||||
# Run the migration files directly from the PG 17 container image.
|
||||
# They're idempotent (IF EXISTS / IF NOT EXISTS guards).
|
||||
local migration_dir="/docker-entrypoint-initdb.d/migrations"
|
||||
local migrations="
|
||||
20250710151649_supabase_read_only_user_default_transaction_read_only.sql
|
||||
20251001204436_predefined_role_grants.sql
|
||||
20251105172723_grant_pg_reload_conf_to_postgres.sql
|
||||
20251121132723_correct_search_path_pgbouncer.sql
|
||||
20260211120934_supabase_privileged_role.sql
|
||||
20260413000000_fix-authenticator-session-preload-libraries.sql
|
||||
20260421000001_rescope_pg_graphql_access_trigger.sql
|
||||
"
|
||||
|
||||
for m in $migrations; do
|
||||
echo " Running: $m"
|
||||
docker exec -i \
|
||||
-e PGPASSWORD="$pg_password" \
|
||||
"$DB_CONTAINER" \
|
||||
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \
|
||||
-f "${migration_dir}/${m}" || warn " $m failed (non-fatal)"
|
||||
done
|
||||
|
||||
# Reconcile extension versions to the target image. pg_upgrade generates
|
||||
# update_extensions.sql, but complete.sh runs it inside the .063 container, so
|
||||
# extensions only get updated to .063's versions. The final image ships newer
|
||||
# .so files, leaving the catalog version behind what's loaded.
|
||||
#
|
||||
# Mirror what update_extensions.sql does, but against the running target image
|
||||
# and for every installed extension: ALTER EXTENSION ... UPDATE brings each one
|
||||
# up to the image's default version. This is generic on purpose - it tracks the
|
||||
# image (matching platform behavior) and stays correct across future image
|
||||
# bumps without maintaining a hardcoded list. ALTER ... UPDATE is a no-op when
|
||||
# already at the default. Per-extension exceptions are caught so one extension
|
||||
# with no update path (e.g. pg_cron 1.6 -> 1.6.4, handled below) does not abort
|
||||
# the rest.
|
||||
info "Reconciling extension versions to the target image"
|
||||
run_sql_on "$DB_CONTAINER" -c "
|
||||
DO \$\$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN SELECT extname FROM pg_extension LOOP
|
||||
BEGIN
|
||||
EXECUTE format('ALTER EXTENSION %I UPDATE', r.extname);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'skipped extension %: %', r.extname, SQLERRM;
|
||||
END;
|
||||
END LOOP;
|
||||
END
|
||||
\$\$;" || warn "extension version reconcile had errors (non-fatal)"
|
||||
|
||||
# pg_cron: PG 15 registers the extension as '1.6'; PG 17 images package it as
|
||||
# '1.6.4' with no '1.6' -> '1.6.4' update path, so ALTER EXTENSION ... UPDATE
|
||||
# has no path to follow. complete.sh only fixes this when pg_cron is owned by
|
||||
# 'postgres' (it drops + recreates). Manually-enabled instances are often
|
||||
# owned by supabase_admin, where that path is skipped. Reconcile the catalog
|
||||
# label directly: the loaded .so and the SQL objects are already
|
||||
# 1.6.4-equivalent (no SQL delta between 1.6 and 1.6.4). No DROP, so any
|
||||
# scheduled jobs are untouched.
|
||||
run_sql_on "$DB_CONTAINER" -c "
|
||||
DO \$\$
|
||||
DECLARE want text;
|
||||
BEGIN
|
||||
SELECT default_version INTO want FROM pg_available_extensions WHERE name = 'pg_cron';
|
||||
IF want IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron' AND extversion <> want) THEN
|
||||
UPDATE pg_extension SET extversion = want WHERE extname = 'pg_cron';
|
||||
END IF;
|
||||
END
|
||||
\$\$;" || warn "pg_cron version reconcile had errors (non-fatal)"
|
||||
}
|
||||
|
||||
# --- Step 10: Verify ------------------------------------------------------
|
||||
|
||||
verify() {
|
||||
info "Verification"
|
||||
|
||||
local version
|
||||
version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SELECT version();" 2>/dev/null | head -n 1)
|
||||
echo " $version"
|
||||
|
||||
echo ""
|
||||
echo " Extensions:"
|
||||
run_sql_on "$DB_CONTAINER" -c \
|
||||
"SELECT extname, extversion FROM pg_extension ORDER BY extname;"
|
||||
|
||||
echo ""
|
||||
info "Upgrade complete!"
|
||||
echo ""
|
||||
echo " To use Postgres 17 going forward, always include the override:"
|
||||
echo " docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d"
|
||||
echo ""
|
||||
echo " Postgres 15 backup: $BACKUP_DIR"
|
||||
echo " pgsodium key backup: ./volumes/db/pgsodium_root.key.bak.pg15"
|
||||
echo " Once satisfied, you can reclaim space:"
|
||||
echo " rm -rf $BACKUP_DIR ./volumes/db/pg17_upgrade_bin_*.tar.gz"
|
||||
echo ""
|
||||
echo " Rollback (if needed):"
|
||||
echo " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
|
||||
echo " 2. rm -rf $DATA_DIR"
|
||||
echo " 3. mv $BACKUP_DIR $DATA_DIR"
|
||||
echo " 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
|
||||
echo " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# --- Main -------------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
echo ""
|
||||
echo "Supabase Self-Hosted: Postgres 15 -> 17 Upgrade"
|
||||
echo "================================================"
|
||||
|
||||
preflight
|
||||
pull_image
|
||||
build_tarball
|
||||
drop_incompatible_extensions
|
||||
stop_and_backup
|
||||
run_upgrade
|
||||
run_complete
|
||||
swap_data
|
||||
start_pg17
|
||||
apply_role_migrations
|
||||
verify
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,145 +0,0 @@
|
||||
# Docker image version updates in docker-compose.yml
|
||||
|
||||
## 2026-08-03
|
||||
- supabase/studio:2026.08.03-sha-022b374 (prev supabase/studio:2026.07.07-sha-a6a04f2)
|
||||
- kong/kong:3.9.3 (prev kong/kong:3.9.1)
|
||||
|
||||
## 2026-07-07
|
||||
- supabase/studio:2026.07.07-sha-a6a04f2 (prev supabase/studio:2026.06.03-sha-0bca601)
|
||||
|
||||
## 2026-06-17
|
||||
- supabase/postgres:17.6.1.136 (prev supabase/postgres:15.8.1.085)
|
||||
|
||||
## 2026-06-03
|
||||
- supabase/studio:2026.06.03-sha-0bca601 (prev supabase/studio:2026.04.27-sha-5f60601)
|
||||
- supabase/gotrue:v2.189.0 (prev supabase/gotrue:v2.186.0)
|
||||
- postgrest/postgrest:v14.12 (prev postgrest/postgrest:v14.8)
|
||||
- supabase/realtime:v2.102.3 (prev supabase/realtime:v2.76.5)
|
||||
- supabase/storage-api:v1.60.4 (prev supabase/storage-api:v1.48.26)
|
||||
- supabase/postgres-meta:v0.96.6 (prev supabase/postgres-meta:v0.96.3)
|
||||
- supabase/edge-runtime:v1.74.0 (prev supabase/edge-runtime:v1.71.2)
|
||||
- supabase/supavisor:2.9.5 (prev supabase/supavisor:2.7.4)
|
||||
- supabase/logflare:1.43.1 (prev supabase/logflare:1.36.1)
|
||||
|
||||
## 2026-04-27
|
||||
- supabase/studio:2026.04.27-sha-5f60601 (prev supabase/studio:2026.04.08-sha-205cbe7)
|
||||
|
||||
## 2026-04-08
|
||||
- supabase/studio:2026.04.08-sha-205cbe7 (prev supabase/studio:2026.03.16-sha-5528817)
|
||||
- postgrest/postgrest:v14.8 (prev postgrest/postgrest:v14.6)
|
||||
- supabase/storage-api:v1.48.26 (prev supabase/storage-api:v1.44.2)
|
||||
- supabase/postgres-meta:v0.96.3 (prev supabase/postgres-meta:v0.95.2)
|
||||
- supabase/logflare:1.36.1 (prev supabase/logflare:1.31.2)
|
||||
|
||||
## 2026-03-16
|
||||
- supabase/studio:2026.03.16-sha-5528817 (prev supabase/studio:2026.02.16-sha-26c615c)
|
||||
- kong/kong:3.9.1 (prev kong:2.8.1)
|
||||
- postgrest/postgrest:v14.6 (prev postgrest/postgrest:v14.5)
|
||||
- supabase/storage-api:v1.44.2 (prev supabase/storage-api:v1.37.8)
|
||||
- supabase/edge-runtime:v1.71.2 (prev supabase/edge-runtime:v1.70.3)
|
||||
|
||||
## 2026-02-16
|
||||
- supabase/studio:2026.02.16-sha-26c615c (prev supabase/studio:2026.01.27-sha-6aa59ff)
|
||||
- supabase/gotrue:v2.186.0 (prev supabase/gotrue:v2.185.0)
|
||||
- postgrest/postgrest:v14.5 (prev postgrest/postgrest:v14.3)
|
||||
- supabase/realtime:v2.76.5 (prev supabase/realtime:v2.72.0)
|
||||
- supabase/storage-api:v1.37.8 (prev supabase/storage-api:v1.37.1)
|
||||
- supabase/edge-runtime:v1.70.3 (prev supabase/edge-runtime:v1.70.0)
|
||||
- supabase/logflare:1.31.2 (prev supabase/logflare:1.30.3)
|
||||
- timberio/vector:0.53.0-alpine (prev timberio/vector:0.28.1-alpine)
|
||||
|
||||
## 2026-02-05
|
||||
- supabase/storage-api:v1.37.1 (prev supabase/storage-api:v1.33.5)
|
||||
|
||||
## 2026-01-27
|
||||
- supabase/studio:2026.01.27-sha-6aa59ff (prev supabase/studio:2025.12.17-sha-43f4f7f)
|
||||
- supabase/gotrue:v2.185.0 (prev supabase/gotrue:v2.184.0)
|
||||
- postgrest/postgrest:v14.3 (prev postgrest/postgrest:v14.1)
|
||||
- supabase/realtime:v2.72.0 (prev supabase/realtime:v2.68.0)
|
||||
- supabase/storage-api:v1.33.5 (prev supabase/storage-api:v1.33.0)
|
||||
- darthsim/imgproxy:v3.30.1 (prev darthsim/imgproxy:v3.8.0)
|
||||
- supabase/postgres-meta:v0.95.2 (prev supabase/postgres-meta:v0.95.1)
|
||||
- supabase/edge-runtime:v1.70.0 (prev supabase/edge-runtime:v1.69.28)
|
||||
- supabase/logflare:1.30.3 (prev supabase/logflare:1.27.0)
|
||||
|
||||
## 2025-12-18
|
||||
- supabase/studio:2025.12.17-sha-43f4f7f (prev supabase/studio:2025.12.09-sha-434634f)
|
||||
- supabase/gotrue:v2.184.0 (prev supabase/gotrue:v2.183.0)
|
||||
- supabase/postgres-meta:v0.95.1 (prev supabase/postgres-meta:v0.93.1)
|
||||
- supabase/logflare:1.27.0 (prev supabase/logflare:1.26.25)
|
||||
|
||||
## 2025-12-10
|
||||
- supabase/studio:2025.12.09-sha-434634f (prev supabase/studio:2025.11.26-sha-8f096b5)
|
||||
- postgrest/postgrest:v14.1 (prev postgrest/postgrest:v13.0.7)
|
||||
- supabase/realtime:v2.68.0 (prev supabase/realtime:v2.65.3)
|
||||
- supabase/storage-api:v1.33.0 (prev supabase/storage-api:v1.32.0)
|
||||
- supabase/edge-runtime:v1.69.28 (prev supabase/edge-runtime:v1.69.25)
|
||||
- supabase/logflare:1.26.25 (prev supabase/logflare:1.26.13)
|
||||
|
||||
## 2025-11-26
|
||||
- supabase/studio:2025.11.26-sha-8f096b5 (prev supabase/studio:2025.11.24-sha-d990ae8)
|
||||
- supabase/realtime:v2.65.3 (prev supabase/realtime:v2.65.2)
|
||||
- supabase/logflare:1.26.13 (prev supabase/logflare:1.26.12)
|
||||
|
||||
## 2025-11-25
|
||||
- supabase/studio:2025.11.24-sha-d990ae8 (prev supabase/studio:2025.11.10-sha-5291fe3)
|
||||
- supabase/gotrue:v2.183.0 (prev supabase/gotrue:v2.182.1)
|
||||
- supabase/realtime:v2.65.2 (prev supabase/realtime:v2.63.0)
|
||||
- supabase/storage-api:v1.32.0 (prev supabase/storage-api:v1.29.0)
|
||||
- supabase/edge-runtime:v1.69.25 (prev supabase/edge-runtime:v1.69.23)
|
||||
- supabase/logflare:1.26.12 (prev supabase/logflare:1.22.6)
|
||||
|
||||
## 2025-11-12
|
||||
- supabase/studio:2025.11.10-sha-5291fe3 (prev supabase/studio:2025.10.27-sha-85b84e0)
|
||||
- supabase/gotrue:v2.182.1 (prev supabase/gotrue:v2.180.0)
|
||||
- supabase/realtime:v2.63.0 (prev supabase/realtime:v2.57.2)
|
||||
- supabase/storage-api:v1.29.0 (prev supabase/storage-api:v1.28.2)
|
||||
- supabase/edge-runtime:v1.69.23 (prev supabase/edge-runtime:v1.69.15)
|
||||
- supabase/supavisor:2.7.4 (prev supabase/supavisor:2.7.3)
|
||||
|
||||
## 2025-10-28
|
||||
- supabase/studio:2025.10.27-sha-85b84e0 (prev supabase/studio:2025.10.20-sha-5005fc6)
|
||||
- supabase/realtime:v2.57.2 (prev supabase/realtime:v2.56.0)
|
||||
- supabase/storage-api:v1.28.2 (prev supabase/storage-api:v1.28.1)
|
||||
- supabase/postgres-meta:v0.93.1 (prev supabase/postgres-meta:v0.93.0)
|
||||
- supabase/edge-runtime:v1.69.15 (prev supabase/edge-runtime:v1.69.14)
|
||||
|
||||
## 2025-10-21
|
||||
- supabase/studio:2025.10.20-sha-5005fc6 (prev supabase/studio:2025.10.01-sha-8460121)
|
||||
- supabase/realtime:v2.56.0 (prev supabase/realtime:v2.51.11)
|
||||
- supabase/storage-api:v1.28.1 (prev supabase/storage-api:v1.28.0)
|
||||
- supabase/postgres-meta:v0.93.0 (prev supabase/postgres-meta:v0.91.6)
|
||||
- supabase/edge-runtime:v1.69.14 (prev supabase/edge-runtime:v1.69.6)
|
||||
- supabase/supavisor:2.7.3 (prev supabase/supavisor:2.7.0)
|
||||
|
||||
## 2025-10-13
|
||||
- supabase/logflare:1.22.6 (prev supabase/logflare:1.22.4)
|
||||
|
||||
## 2025-10-08
|
||||
- supabase/studio:2025.10.01-sha-8460121 (prev supabase/studio:2025.06.30-sha-6f5982d)
|
||||
- supabase/gotrue:v2.180.0 (prev supabase/gotrue:v2.177.0)
|
||||
- postgrest/postgrest:v13.0.7 (prev postgrest/postgrest:v12.2.12)
|
||||
- supabase/realtime:v2.51.11 (prev supabase/realtime:v2.34.47)
|
||||
- supabase/storage-api:v1.28.0 (prev supabase/storage-api:v1.25.7)
|
||||
- supabase/postgres-meta:v0.91.6 (prev supabase/postgres-meta:v0.91.0)
|
||||
- supabase/logflare:1.22.4 (prev supabase/logflare:1.14.2)
|
||||
- supabase/postgres:15.8.1.085 (prev supabase/postgres:15.8.1.060)
|
||||
- supabase/supavisor:2.7.0 (prev supabase/supavisor:2.5.7)
|
||||
|
||||
## 2025-07-15
|
||||
- supabase/gotrue:v2.177.0 (prev supabase/gotrue:v2.176.1)
|
||||
- supabase/storage-api:v1.25.7 (prev supabase/storage-api:v1.24.7)
|
||||
- supabase/postgres-meta:v0.91.0 (prev supabase/postgres-meta:v0.89.3)
|
||||
- supabase/supavisor:2.5.7 (prev supabase/supavisor:2.5.6)
|
||||
|
||||
## 2025-07-02
|
||||
- supabase/studio:2025.06.30-sha-6f5982d (prev supabase/studio:2025.06.02-sha-8f2993d)
|
||||
- supabase/gotrue:v2.176.1 (prev supabase/gotrue:v2.174.0)
|
||||
- supabase/storage-api:v1.24.7 (prev supabase/storage-api:v1.23.0)
|
||||
- supabase/supavisor:2.5.6 (prev supabase/supavisor:2.5.1)
|
||||
|
||||
## 2025-06-03
|
||||
- supabase/studio:2025.06.02-sha-8f2993d (prev supabase/studio:2025.05.19-sha-3487831)
|
||||
- supabase/gotrue:v2.174.0 (prev supabase/gotrue:v2.172.1)
|
||||
- supabase/storage-api:v1.23.0 (prev supabase/storage-api:v1.22.17)
|
||||
- supabase/postgres-meta:v0.89.3 (prev supabase/postgres-meta:v0.89.0)
|
||||
@@ -1,223 +0,0 @@
|
||||
resources:
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: auth
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: auth
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: auth
|
||||
port_value: 9999
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
http_health_check:
|
||||
path: /health
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: rest
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: rest
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: rest
|
||||
port_value: 3000
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
http_health_check:
|
||||
path: /
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: realtime
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: realtime
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: realtime-dev.supabase-realtime
|
||||
port_value: 4000
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
http_health_check:
|
||||
path: /
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: storage
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: storage
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: storage
|
||||
port_value: 5000
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
http_health_check:
|
||||
path: /status
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: functions
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: functions
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: functions
|
||||
port_value: 9000
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
tcp_health_check: {}
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: meta
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: meta
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: meta
|
||||
port_value: 8080
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
http_health_check:
|
||||
path: /health
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
|
||||
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
|
||||
name: studio
|
||||
connect_timeout: 5s
|
||||
type: STRICT_DNS
|
||||
dns_refresh_rate: 5s
|
||||
dns_failure_refresh_rate:
|
||||
base_interval: 1s
|
||||
max_interval: 1s
|
||||
lb_policy: ROUND_ROBIN
|
||||
load_assignment:
|
||||
cluster_name: studio
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: studio
|
||||
port_value: 3000
|
||||
health_checks:
|
||||
- timeout: 2s
|
||||
interval: 5s
|
||||
unhealthy_threshold: 3
|
||||
healthy_threshold: 2
|
||||
http_health_check:
|
||||
path: /project/default
|
||||
circuit_breakers:
|
||||
thresholds:
|
||||
- priority: DEFAULT
|
||||
max_connections: 10000
|
||||
max_pending_requests: 10000
|
||||
max_requests: 10000
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Generate SHA1 base64 hash for Envoy basic auth user list
|
||||
PASSWORD_HASH=$(printf '%s' "${DASHBOARD_PASSWORD}" | openssl sha1 -binary | openssl base64)
|
||||
DASHBOARD_BASIC_AUTH="${DASHBOARD_USERNAME}:{SHA}${PASSWORD_HASH}"
|
||||
|
||||
echo "Generating Envoy configuration..."
|
||||
|
||||
# Process the lds.yaml template with environment variables using sed
|
||||
# Using | as delimiter since JWT tokens contain /
|
||||
sed -e "s|\${ANON_KEY}|${ANON_KEY}|g" \
|
||||
-e "s|\${ANON_KEY_ASYMMETRIC}|${ANON_KEY_ASYMMETRIC}|g" \
|
||||
-e "s|\${SERVICE_ROLE_KEY}|${SERVICE_ROLE_KEY}|g" \
|
||||
-e "s|\${SERVICE_ROLE_KEY_ASYMMETRIC}|${SERVICE_ROLE_KEY_ASYMMETRIC}|g" \
|
||||
-e "s|\${SUPABASE_PUBLISHABLE_KEY}|${SUPABASE_PUBLISHABLE_KEY}|g" \
|
||||
-e "s|\${SUPABASE_SECRET_KEY}|${SUPABASE_SECRET_KEY}|g" \
|
||||
-e "s|\${SUPABASE_PUBLIC_URL}|${SUPABASE_PUBLIC_URL}|g" \
|
||||
-e "s|\${DASHBOARD_BASIC_AUTH}|${DASHBOARD_BASIC_AUTH}|g" \
|
||||
/etc/envoy/lds.template.yaml > /etc/envoy/lds.yaml
|
||||
|
||||
if [ -n "$SUPABASE_SECRET_KEY" ] && \
|
||||
[ -n "$SUPABASE_PUBLISHABLE_KEY" ] && \
|
||||
[ -n "$SERVICE_ROLE_KEY_ASYMMETRIC" ] && \
|
||||
[ -n "$ANON_KEY_ASYMMETRIC" ]; then
|
||||
echo "Envoy sb_ key translation enabled"
|
||||
else
|
||||
echo "Envoy running in legacy API key mode (sb_ keys disabled)"
|
||||
fi
|
||||
|
||||
echo "Envoy configuration generated successfully"
|
||||
echo "Starting Envoy..."
|
||||
|
||||
# Start Envoy
|
||||
exec envoy -c /etc/envoy/envoy.yaml "$@"
|
||||
@@ -1,27 +0,0 @@
|
||||
dynamic_resources:
|
||||
cds_config:
|
||||
path_config_source:
|
||||
path: /etc/envoy/cds.yaml
|
||||
resource_api_version: V3
|
||||
lds_config:
|
||||
path_config_source:
|
||||
path: /etc/envoy/lds.yaml
|
||||
resource_api_version: V3
|
||||
|
||||
node:
|
||||
cluster: supabase_cluster
|
||||
id: supabase_node
|
||||
|
||||
overload_manager:
|
||||
resource_monitors:
|
||||
- name: envoy.resource_monitors.global_downstream_max_connections
|
||||
typed_config:
|
||||
'@type': >-
|
||||
type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig
|
||||
max_active_downstream_connections: 30000
|
||||
|
||||
admin:
|
||||
address:
|
||||
socket_address:
|
||||
address: 127.0.0.1
|
||||
port_value: 9901
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
|
||||
# and performs environment variable substitution in the declarative config.
|
||||
|
||||
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
|
||||
# When opaque keys are not configured (empty env vars), expressions fall through
|
||||
# to legacy-only behavior - just passing apikey as-is.
|
||||
#
|
||||
# Full expression logic (when opaque keys are configured):
|
||||
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
|
||||
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
|
||||
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
|
||||
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
|
||||
|
||||
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
|
||||
# Opaque keys configured -> full translation expressions
|
||||
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
|
||||
|
||||
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
|
||||
# via query string), outputs to x-api-key header which Realtime checks first.
|
||||
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
|
||||
else
|
||||
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
|
||||
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
|
||||
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
|
||||
fi
|
||||
|
||||
# Substitute environment variables in the Kong declarative config.
|
||||
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
|
||||
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
|
||||
awk '{
|
||||
result = ""
|
||||
rest = $0
|
||||
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
|
||||
varname = substr(rest, RSTART + 1, RLENGTH - 1)
|
||||
if (varname in ENVIRON) {
|
||||
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
|
||||
} else {
|
||||
result = result substr(rest, 1, RSTART + RLENGTH - 1)
|
||||
}
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print result rest
|
||||
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
|
||||
|
||||
# Remove empty key-auth credentials (unconfigured opaque keys)
|
||||
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
|
||||
|
||||
exec /entrypoint.sh kong docker-start
|
||||
@@ -1,467 +0,0 @@
|
||||
_format_version: '2.1'
|
||||
_transform: true
|
||||
|
||||
###
|
||||
### Consumers / Users
|
||||
###
|
||||
consumers:
|
||||
- username: DASHBOARD
|
||||
- username: anon
|
||||
keyauth_credentials:
|
||||
- key: $SUPABASE_ANON_KEY
|
||||
- key: $SUPABASE_PUBLISHABLE_KEY
|
||||
- username: service_role
|
||||
keyauth_credentials:
|
||||
- key: $SUPABASE_SERVICE_KEY
|
||||
- key: $SUPABASE_SECRET_KEY
|
||||
|
||||
###
|
||||
### Access Control List
|
||||
###
|
||||
acls:
|
||||
- consumer: anon
|
||||
group: anon
|
||||
- consumer: service_role
|
||||
group: admin
|
||||
|
||||
###
|
||||
### Dashboard credentials
|
||||
###
|
||||
basicauth_credentials:
|
||||
- consumer: DASHBOARD
|
||||
username: '$DASHBOARD_USERNAME'
|
||||
password: '$DASHBOARD_PASSWORD'
|
||||
|
||||
###
|
||||
### API Routes
|
||||
###
|
||||
services:
|
||||
## Open Auth routes
|
||||
- name: auth-v1-open
|
||||
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
|
||||
url: http://auth:9999/verify
|
||||
routes:
|
||||
- name: auth-v1-open
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/verify
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-callback
|
||||
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
|
||||
url: http://auth:9999/callback
|
||||
routes:
|
||||
- name: auth-v1-open-callback
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/callback
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-authorize
|
||||
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
|
||||
url: http://auth:9999/authorize
|
||||
routes:
|
||||
- name: auth-v1-open-authorize
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/authorize
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-jwks
|
||||
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
|
||||
url: http://auth:9999/.well-known/jwks.json
|
||||
routes:
|
||||
- name: auth-v1-open-jwks
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/.well-known/jwks.json
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
- name: auth-v1-open-sso-acs
|
||||
url: "http://auth:9999/sso/saml/acs"
|
||||
routes:
|
||||
- name: auth-v1-open-sso-acs
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/sso/saml/acs
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
- name: auth-v1-open-sso-metadata
|
||||
url: "http://auth:9999/sso/saml/metadata"
|
||||
routes:
|
||||
- name: auth-v1-open-sso-metadata
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/sso/saml/metadata
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
## Secure Auth routes
|
||||
- name: auth-v1
|
||||
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
|
||||
url: http://auth:9999/
|
||||
routes:
|
||||
- name: auth-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## OpenAPI root - admin only
|
||||
- name: rest-v1-openapi
|
||||
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> <http://rest:3000/> (admin only). See <https://github.com/orgs/supabase/discussions/42949>'
|
||||
url: http://rest:3000/
|
||||
routes:
|
||||
- name: rest-v1-openapi-root
|
||||
strip_path: true
|
||||
expression: 'http.path == "/rest/v1/"'
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
|
||||
## Secure PostgREST routes
|
||||
- name: rest-v1
|
||||
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
|
||||
url: http://rest:3000/
|
||||
routes:
|
||||
- name: rest-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /rest/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Secure GraphQL routes
|
||||
- name: graphql-v1
|
||||
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
|
||||
url: http://rest:3000/rpc/graphql
|
||||
routes:
|
||||
- name: graphql-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /graphql/v1
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Content-Profile: graphql_public"
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Secure Realtime routes
|
||||
- name: realtime-v1-ws
|
||||
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
|
||||
url: http://realtime-dev.supabase-realtime:4000/socket
|
||||
protocol: ws
|
||||
routes:
|
||||
- name: realtime-v1-ws
|
||||
strip_path: true
|
||||
paths:
|
||||
- /realtime/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "x-api-key:$LUA_RT_WS_EXPR"
|
||||
replace:
|
||||
querystring:
|
||||
- "apikey:$LUA_RT_WS_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
# Block access to /realtime/v1/api/openapi
|
||||
- name: realtime-v1-rest-openapi
|
||||
_comment: 'Realtime: /realtime/v1/api/openapi/* -> http://realtime:4000/api/openapi/* (blocked)'
|
||||
url: http://realtime-dev.supabase-realtime:4000/api/openapi
|
||||
protocol: http
|
||||
routes:
|
||||
- name: realtime-v1-rest-openapi
|
||||
strip_path: true
|
||||
paths:
|
||||
- /realtime/v1/api/openapi
|
||||
plugins:
|
||||
- name: request-termination
|
||||
config:
|
||||
status_code: 403
|
||||
message: "Access is forbidden."
|
||||
|
||||
# Block access to /realtime/v1/api/tenants
|
||||
- name: realtime-v1-rest-tenants
|
||||
_comment: 'Realtime: /realtime/v1/api/tenants/* -> http://realtime:4000/api/tenants/* (blocked)'
|
||||
url: http://realtime-dev.supabase-realtime:4000/api/tenants
|
||||
protocol: http
|
||||
routes:
|
||||
- name: realtime-v1-rest-tenants
|
||||
strip_path: true
|
||||
paths:
|
||||
- /realtime/v1/api/tenants
|
||||
plugins:
|
||||
- name: request-termination
|
||||
config:
|
||||
status_code: 403
|
||||
message: "Access is forbidden."
|
||||
|
||||
- name: realtime-v1-rest
|
||||
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
|
||||
url: http://realtime-dev.supabase-realtime:4000/api
|
||||
protocol: http
|
||||
routes:
|
||||
- name: realtime-v1-rest
|
||||
strip_path: true
|
||||
paths:
|
||||
- /realtime/v1/api
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Storage API endpoint (with Authorization header transformation).
|
||||
## No key-auth - S3 protocol requests don't carry an apikey header.
|
||||
##
|
||||
## The request-transformer translates opaque API keys to asymmetric JWTs
|
||||
## and passes through existing Authorization headers (user JWTs, AWS SigV4).
|
||||
## When no Authorization or apikey header is present (S3 presigned URLs),
|
||||
## the Lua expression evaluates to nil which Kong renders as empty string.
|
||||
## The post-function strips this empty header so Storage's S3 signature
|
||||
## verification falls through to query-parameter parsing.
|
||||
- name: storage-v1
|
||||
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
|
||||
url: http://storage:5000/
|
||||
routes:
|
||||
- name: storage-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /storage/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: request-transformer
|
||||
config:
|
||||
add:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
replace:
|
||||
headers:
|
||||
- "Authorization: $LUA_AUTH_EXPR"
|
||||
- name: post-function
|
||||
config:
|
||||
access:
|
||||
- |
|
||||
local auth = kong.request.get_header("authorization")
|
||||
if auth == nil or auth == "" or auth:find("^%s*$") then
|
||||
kong.service.request.clear_header("authorization")
|
||||
end
|
||||
|
||||
## Edge Functions routes
|
||||
- name: functions-v1
|
||||
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
|
||||
url: http://functions:9000/
|
||||
read_timeout: 150000
|
||||
routes:
|
||||
- name: functions-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /functions/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
- name: well-known-oauth
|
||||
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
|
||||
url: http://auth:9999/.well-known/oauth-authorization-server
|
||||
routes:
|
||||
- name: well-known-oauth
|
||||
strip_path: true
|
||||
paths:
|
||||
- /.well-known/oauth-authorization-server
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
## Analytics routes
|
||||
## Not used - Studio and Vector talk directly to analytics via Docker networking.
|
||||
## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth.
|
||||
# - name: analytics-v1-api
|
||||
# _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*'
|
||||
# url: http://analytics:4000/api/endpoints
|
||||
# routes:
|
||||
# - name: analytics-v1-api
|
||||
# strip_path: true
|
||||
# paths:
|
||||
# - /analytics/v1/api/endpoints/
|
||||
# - name: analytics-v1
|
||||
# _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
|
||||
# url: http://analytics:4000/
|
||||
# routes:
|
||||
# - name: dashboard-v1-all
|
||||
# strip_path: true
|
||||
# paths:
|
||||
# - /analytics/v1
|
||||
# plugins:
|
||||
# - name: cors
|
||||
# - name: basic-auth
|
||||
# config:
|
||||
# hide_credentials: true
|
||||
|
||||
## Secure Database routes
|
||||
- name: meta
|
||||
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
|
||||
url: http://meta:8080/
|
||||
routes:
|
||||
- name: meta-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /pg/
|
||||
plugins:
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
|
||||
## Block access to /api/mcp
|
||||
- name: mcp-blocker
|
||||
_comment: 'Block direct access to /api/mcp'
|
||||
url: http://studio:3000/api/mcp
|
||||
routes:
|
||||
- name: mcp-blocker-route
|
||||
strip_path: true
|
||||
paths:
|
||||
- /api/mcp
|
||||
plugins:
|
||||
- name: request-termination
|
||||
config:
|
||||
status_code: 403
|
||||
message: "Access is forbidden."
|
||||
|
||||
## MCP endpoint - local access
|
||||
- name: mcp
|
||||
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
|
||||
url: http://studio:3000/api/mcp
|
||||
routes:
|
||||
- name: mcp
|
||||
strip_path: true
|
||||
paths:
|
||||
- /mcp
|
||||
plugins:
|
||||
# Block access to /mcp by default
|
||||
- name: request-termination
|
||||
config:
|
||||
status_code: 403
|
||||
message: "Access is forbidden."
|
||||
# Enable local access (danger zone!)
|
||||
# 1. Comment out the 'request-termination' section above
|
||||
# 2. Uncomment the entire section below, including 'deny'
|
||||
# 3. Add your local IPs to the 'allow' list
|
||||
#- name: cors
|
||||
#- name: ip-restriction
|
||||
# config:
|
||||
# allow:
|
||||
# - 127.0.0.1
|
||||
# - ::1
|
||||
# deny: []
|
||||
|
||||
## Protected Dashboard - catch all remaining routes
|
||||
- name: dashboard
|
||||
_comment: 'Studio: /* -> http://studio:3000/*'
|
||||
url: http://studio:3000/
|
||||
routes:
|
||||
- name: dashboard-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: basic-auth
|
||||
config:
|
||||
hide_credentials: true
|
||||
@@ -1,3 +0,0 @@
|
||||
\set pguser `echo "$POSTGRES_USER"`
|
||||
|
||||
CREATE DATABASE _supabase WITH OWNER :pguser;
|
||||
@@ -1,5 +0,0 @@
|
||||
\set jwt_secret `echo "$JWT_SECRET"`
|
||||
\set jwt_exp `echo "$JWT_EXP"`
|
||||
|
||||
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret';
|
||||
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user