alissa-tools-github-revloop 0.31.11__tar.gz → 0.31.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {alissa_tools_github_revloop-0.31.11/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.12}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/fleet_vitals.py +175 -27
  3. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/loop.py +16 -1
  4. alissa_tools_github_revloop-0.31.12/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_revloop-0.31.11/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  17. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  18. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  19. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  21. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  22. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  23. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  25. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  26. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/state.py +0 -0
  27. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  28. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/version.py +0 -0
  29. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  30. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  31. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  32. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  33. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  34. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  35. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  36. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.11 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.11
3
+ Version: 0.31.12
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -50,15 +50,32 @@ answers (`FleetVitalsPusher.push_now`). Until Studio's schema carries the
50
50
  block (the design's lane L3), a strict 400 naming `idle` makes the pusher
51
51
  resend without it and leave it out for IDLE_REPROBE_S: the vitals the Factory
52
52
  already renders never go dark over a field nobody reads yet.
53
+
54
+ The managed fleet's smoke (issue #159, studio.alissa.app provisioner design
55
+ §2.7, lane L9) adds the `config` block -- what this seat DERIVED, in its own
56
+ words: the feed owner actor id, the effective review allowlist, the reviewer
57
+ login(s) it requests (none: the reviewer seat is the one requested) and its
58
+ own GitHub login, the reviewer identity SM4 compares with the rev seat
59
+ credential. Every value is an identity or a name, never a credential: the
60
+ block is built from the config the watcher RUNS on, not from the
61
+ environment, and any value carrying a secret the process holds
62
+ (`SECRET_ENV`, plus the variable `reviewer_token_env` names) is blanked
63
+ before it can leave. The daemon's own pass sends it; the console's
64
+ out-of-pass push leaves it out (that process never resolves the feed
65
+ authority nor refreshes the allowlist, so its answer would be a different
66
+ one). A Studio whose strict schema predates the block gets the `idle`
67
+ treatment: resent without it, offered again after BLOCK_REPROBE_S.
53
68
  """
54
69
 
55
70
  from __future__ import annotations
56
71
 
57
72
  import json
58
73
  import logging
74
+ import os
75
+ import re
59
76
  import time
60
77
  from datetime import datetime, timezone
61
- from typing import TYPE_CHECKING, Any, Callable
78
+ from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
62
79
 
63
80
  from . import parking
64
81
  from .alissa import parse_session_name, session_repo_slug
@@ -68,7 +85,7 @@ from .alissa_client import (
68
85
  AlissaClient,
69
86
  AlissaError,
70
87
  )
71
- from .config import Config
88
+ from .config import REPOS_BOWS, Config
72
89
  from .proc import CommandError, run as proc_run
73
90
 
74
91
  if TYPE_CHECKING: # pragma: no cover - typing only
@@ -97,11 +114,31 @@ VITALS_PUSHED = "pushed"
97
114
  VITALS_SKIPPED = "skipped"
98
115
  VITALS_FAILED = "failed"
99
116
 
100
- # How long a snapshot leaves the `idle` block out after Studio refused it as
101
- # an unknown key (a schema that predates seat parking), before it offers the
102
- # block again. An hour: one extra POST an hour against an old Studio, and a
103
- # newly deployed one starts receiving the block within the hour.
104
- IDLE_REPROBE_S = 3600.0
117
+ # The blocks Studio's schema gained after the base contract, each in its own
118
+ # lane: `idle` (seat parking L3) and `config` (provisioner L5). A strict 400
119
+ # naming one of them is an old Studio, not a bad snapshot.
120
+ OPTIONAL_BLOCKS = ("idle", "config")
121
+
122
+ # How long a snapshot leaves an optional block out after Studio refused it as
123
+ # an unknown key, before it offers the block again. An hour: one extra POST
124
+ # an hour against an old Studio, and a newly deployed one starts receiving
125
+ # the block within the hour.
126
+ BLOCK_REPROBE_S = 3600.0
127
+ IDLE_REPROBE_S = BLOCK_REPROBE_S
128
+
129
+ # The `config` block's keys (provisioner design §2.7 / lane L9), in order.
130
+ CONFIG_KEYS = ("feedOwnerActorId", "repos", "reviewersRequested", "ghLogin")
131
+
132
+ # The credentials a revloop container holds (the provisioner's variable
133
+ # collection, design §2.6, plus gh's own fallback name); the variable a
134
+ # `reviewer_token_env` names is added per config. No `config` value may carry
135
+ # one. A value shorter than SECRET_MIN is not a credential and is not matched
136
+ # (a passcode of "1" must not blank every login holding a 1).
137
+ SECRET_ENV = (
138
+ "GH_TOKEN", "GITHUB_TOKEN", "ALISSA_API_TOKEN", "ALISSA_UI_PASSCODE",
139
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
140
+ )
141
+ SECRET_MIN = 8
105
142
 
106
143
  # The operator's lever per inbox kind, in the Factory's `lever` slot. The
107
144
  # cap-out and the stability hold are both lifted by the same re-entry ack;
@@ -258,6 +295,7 @@ def build_snapshot(
258
295
  queue_depth: "int | None",
259
296
  repos: "tuple[str, ...]" = (),
260
297
  now: "int | float | None" = None,
298
+ config: "Mapping | None" = None,
261
299
  ) -> dict:
262
300
  """The contract body for this pass, off the console's builders.
263
301
 
@@ -273,6 +311,9 @@ def build_snapshot(
273
311
  timers from the newest poll snapshot, live / managed from the SAME roster
274
312
  read as `sessions`, queued from the alissa queue, and `drainedAt` while
275
313
  a drain holds.
314
+
315
+ `config` is the seat's `config_block`, or None to leave the key out (the
316
+ console's push, which has no derived answer to give).
276
317
  """
277
318
  stamp = time.time() if now is None else now
278
319
  snaps = sources.snapshots(DURATION_POINTS)
@@ -304,7 +345,7 @@ def build_snapshot(
304
345
  live=live, managed=managed, queued=sources.queued(),
305
346
  drain=sources.drain(), now=stamp,
306
347
  )
307
- return {
348
+ snapshot = {
308
349
  "schemaVersion": SCHEMA_VERSION,
309
350
  "seat": SEAT,
310
351
  "asOf": iso_utc(stamp),
@@ -326,6 +367,72 @@ def build_snapshot(
326
367
  "inbox": inbox,
327
368
  "idle": idle,
328
369
  }
370
+ if config is not None:
371
+ snapshot["config"] = dict(config)
372
+ return snapshot
373
+
374
+
375
+ def _carries_secret(value: str, secrets: "Sequence[str]") -> bool:
376
+ return any(secret in value for secret in secrets)
377
+
378
+
379
+ def config_block(
380
+ config: Config,
381
+ *,
382
+ gh_login: "str | None",
383
+ environ: "Mapping[str, str] | None" = None,
384
+ ) -> dict:
385
+ """The seat's own word on what it derived (issue #159): four fields,
386
+ each from the config the daemon is RUNNING on, never from the raw env.
387
+
388
+ * `feedOwnerActorId` -- the bodies-of-work authority under
389
+ `repos_source: bows` (the token's own actor, resolved at boot, unless
390
+ `bow_owners` names one). Null in static mode (no feed is read) and when
391
+ several owners are trusted (there is no single answer to quote).
392
+ * `repos` -- the effective allowlist, sorted: under bows the derived
393
+ union the last refresh bound, in static mode the configured list
394
+ (empty there means "every repo that requests me").
395
+ * `reviewersRequested` -- always empty: the reviewer seat requests no
396
+ reviewer, it IS the one requested. The key is kept so every seat's
397
+ block has one shape.
398
+ * `ghLogin` -- the reviewer identity the gh credential resolves to (the
399
+ login round counting and every posted review are held to); null when
400
+ unknown.
401
+
402
+ `environ` (default: the process env) supplies the secrets to scan for:
403
+ a value carrying one is blanked -- a list entry dropped, a scalar
404
+ nulled -- and named in a WARNING by field only."""
405
+ env = os.environ if environ is None else environ
406
+ names = SECRET_ENV + (
407
+ (config.reviewer_token_env,) if config.reviewer_token_env else ()
408
+ )
409
+ secrets = [
410
+ v for k in names if len(v := (env.get(k) or "").strip()) >= SECRET_MIN
411
+ ]
412
+ owners = config.bow_owners if config.repos_source == REPOS_BOWS else ()
413
+ block: dict = {
414
+ "feedOwnerActorId": owners[0] if len(owners) == 1 else None,
415
+ "repos": sorted(config.repos, key=str.casefold),
416
+ "reviewersRequested": [],
417
+ "ghLogin": gh_login or None,
418
+ }
419
+ for key in CONFIG_KEYS:
420
+ value = block[key]
421
+ if isinstance(value, list):
422
+ kept = [v for v in value if not _carries_secret(v, secrets)]
423
+ blanked = len(kept) != len(value)
424
+ block[key] = kept
425
+ else:
426
+ blanked = value is not None and _carries_secret(value, secrets)
427
+ if blanked:
428
+ block[key] = None
429
+ if blanked:
430
+ log.warning(
431
+ "fleet-vitals: the config block's %s carried a credential "
432
+ "this process holds — blanked before sending; check the "
433
+ "variable it is derived from", key,
434
+ )
435
+ return block
329
436
 
330
437
 
331
438
  def body_bytes(snapshot: dict) -> int:
@@ -384,6 +491,7 @@ class FleetVitalsPusher:
384
491
  client: AlissaClient,
385
492
  *,
386
493
  clock: Callable[[], float] = time.time,
494
+ config_reader: "Callable[[], Mapping | None] | None" = None,
387
495
  ):
388
496
  self.config = config
389
497
  self._sources = sources
@@ -394,9 +502,12 @@ class FleetVitalsPusher:
394
502
  # every later refusal is a DEBUG line. The push itself is still
395
503
  # attempted every pass -- the pass after activation lands on its own.
396
504
  self._not_activated_warned = False
397
- # Until when the `idle` block is left out (Studio refused it as an
398
- # unknown key); 0 = offer it.
399
- self._idle_refused_until = 0.0
505
+ # The `config` block's source (the daemon's `_vitals_config`), or
506
+ # None -- the console's pusher -- to leave the block out.
507
+ self._config_reader = config_reader
508
+ # Per optional block: until when it is left out (Studio refused it
509
+ # as an unknown key). Absent = offer it.
510
+ self._refused_until: "dict[str, float]" = {}
400
511
 
401
512
  def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
402
513
  """This pass's contract body, fitted to the API's caps."""
@@ -407,8 +518,24 @@ class FleetVitalsPusher:
407
518
  queue_depth=queue_depth,
408
519
  repos=self.config.repos,
409
520
  now=self._clock(),
521
+ config=self._config(),
410
522
  ))
411
523
 
524
+ def _config(self) -> "Mapping | None":
525
+ """The `config` block, or None. A reader that fails costs the block,
526
+ never the snapshot: the rest of the vitals still go out."""
527
+ if self._config_reader is None:
528
+ return None
529
+ try:
530
+ return self._config_reader()
531
+ except Exception as exc:
532
+ log.warning(
533
+ "fleet-vitals: could not derive the config block (%s: %s) — "
534
+ "pushing the snapshot without it",
535
+ type(exc).__name__, exc,
536
+ )
537
+ return None
538
+
412
539
  def push_once(
413
540
  self, *, heartbeat_at: "int | float", queue_depth: "int | None" = None
414
541
  ) -> str:
@@ -431,8 +558,10 @@ class FleetVitalsPusher:
431
558
  type(exc).__name__, exc,
432
559
  )
433
560
  return VITALS_FAILED
434
- if self._clock() < self._idle_refused_until:
435
- snapshot.pop("idle", None)
561
+ now = self._clock()
562
+ for key, until in self._refused_until.items():
563
+ if now < until:
564
+ snapshot.pop(key, None)
436
565
  if self.config.dry_run:
437
566
  log.info("[dry-run] would push fleet vitals (%s)", describe(snapshot))
438
567
  return VITALS_SKIPPED
@@ -450,10 +579,18 @@ class FleetVitalsPusher:
450
579
  return self.push_once(heartbeat_at=heartbeat, queue_depth=None)
451
580
 
452
581
  @staticmethod
453
- def _refuses_idle(exc: AlissaError) -> bool:
454
- """A 400 that names the `idle` key: a Studio whose strict schema
455
- predates seat parking (the design's lane L3 adds the field)."""
456
- return exc.status == 400 and "idle" in str(exc.detail)
582
+ def _refused_blocks(exc: AlissaError, snapshot: dict) -> "list[str]":
583
+ """The optional blocks of `snapshot` a 400 names: a Studio whose
584
+ strict schema predates them (seat parking's lane L3 adds `idle`, the
585
+ provisioner's lane L5 adds `config`). A word match, so `config` is
586
+ not read into an unrelated message that merely contains it."""
587
+ if exc.status != 400:
588
+ return []
589
+ detail = str(exc.detail)
590
+ return [
591
+ key for key in OPTIONAL_BLOCKS
592
+ if key in snapshot and re.search(rf"\b{key}\b", detail)
593
+ ]
457
594
 
458
595
  def _post(self, snapshot: dict) -> str:
459
596
  try:
@@ -465,16 +602,21 @@ class FleetVitalsPusher:
465
602
  self._warn_failed(exc)
466
603
  return VITALS_FAILED
467
604
  except AlissaError as exc:
468
- if "idle" in snapshot and self._refuses_idle(exc):
469
- self._idle_refused_until = self._clock() + IDLE_REPROBE_S
605
+ refused = self._refused_blocks(exc, snapshot)
606
+ if refused:
607
+ until = self._clock() + BLOCK_REPROBE_S
608
+ for key in refused:
609
+ self._refused_until[key] = until
470
610
  log.warning(
471
- "fleet-vitals: Studio refused the seat-parking `idle` "
472
- "block (status %s: %s) — its schema predates it; "
473
- "resending without it, and leaving it out for %d min",
474
- exc.status, exc.detail, int(IDLE_REPROBE_S // 60),
611
+ "fleet-vitals: Studio refused the %s block(s) (status "
612
+ "%s: %s) — its schema predates them; resending without "
613
+ "them, and leaving them out for %d min",
614
+ ", ".join(f"`{k}`" for k in refused), exc.status,
615
+ exc.detail, int(BLOCK_REPROBE_S // 60),
475
616
  )
476
- trimmed = dict(snapshot)
477
- trimmed.pop("idle")
617
+ trimmed = {
618
+ k: v for k, v in snapshot.items() if k not in refused
619
+ }
478
620
  return self._post(trimmed)
479
621
  self._warn_failed(exc)
480
622
  return VITALS_FAILED
@@ -545,6 +687,7 @@ def build_pusher(
545
687
  *,
546
688
  github: object = None,
547
689
  endpoint: "str | None" = None,
690
+ config_reader: "Callable[[], Mapping | None] | None" = None,
548
691
  ) -> FleetVitalsPusher:
549
692
  """The pusher the watcher wires in when `fleet_vitals_enabled` is on.
550
693
 
@@ -555,7 +698,9 @@ def build_pusher(
555
698
  own `ALISSA_API_TOKEN`); its absence surfaces as the pusher's WARN, never
556
699
  at construction, because a daemon must boot and poll whether or not
557
700
  vitals can authenticate. The import is local because `sources` imports
558
- `loop`, which imports this module.
701
+ `loop`, which imports this module. `config_reader` is the daemon's
702
+ `config` block source (issue #159); the console builds its own pusher
703
+ without one.
559
704
  """
560
705
  from .version import version
561
706
  from .webui.sources import Sources
@@ -565,4 +710,7 @@ def build_pusher(
565
710
  running_version=version.value,
566
711
  run=_console_runner(github),
567
712
  )
568
- return FleetVitalsPusher(config, sources, AlissaClient(base=endpoint))
713
+ return FleetVitalsPusher(
714
+ config, sources, AlissaClient(base=endpoint),
715
+ config_reader=config_reader,
716
+ )
@@ -94,6 +94,7 @@ from .fleet_vitals import (
94
94
  VITALS_SKIPPED,
95
95
  FleetVitalsPusher,
96
96
  build_pusher,
97
+ config_block as fleet_vitals_config_block,
97
98
  )
98
99
  from .loop_events import (
99
100
  LoopEventsEmitter,
@@ -2494,7 +2495,8 @@ class ReviewWatcher:
2494
2495
  # console sidecar need not be running.
2495
2496
  self._fleet_vitals: FleetVitalsPusher | None = (
2496
2497
  build_pusher(
2497
- config, github=self.github, endpoint=config.alissa_endpoint
2498
+ config, github=self.github, endpoint=config.alissa_endpoint,
2499
+ config_reader=self._vitals_config,
2498
2500
  )
2499
2501
  if config.fleet_vitals_enabled
2500
2502
  else None
@@ -7754,6 +7756,19 @@ class ReviewWatcher:
7754
7756
  )
7755
7757
  return VITALS_FAILED
7756
7758
 
7759
+ def _vitals_config(self) -> dict:
7760
+ """The snapshot's `config` block (issue #159), read off what this
7761
+ watcher RUNS on: `self.config` is re-bound by every allowlist
7762
+ refresh (`_apply_repos`) and carries the feed authority
7763
+ `resolve_feed_authority` settled at boot, so the block reports the
7764
+ derived answer, not the configured one -- the pusher itself was built
7765
+ with the boot config. The login is the reviewer identity's (memoised
7766
+ after the first read; a failed read fails the block, never the
7767
+ push)."""
7768
+ return fleet_vitals_config_block(
7769
+ self.config, gh_login=self.github.login
7770
+ )
7771
+
7757
7772
  def _note_ledger_unwritable(self) -> None:
7758
7773
  """Report a pass refused because the ledger cannot record it.
7759
7774
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.11
3
+ Version: 0.31.12
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera