dirigent-core 0.27.0__tar.gz → 0.28.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/PKG-INFO +5 -4
  2. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/pyproject.toml +5 -4
  3. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/pyproject.toml.orig +5 -4
  4. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +35 -0
  5. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alerting.py +7 -2
  6. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/artifacts.py +46 -6
  7. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/auth.py +41 -11
  8. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/config.py +203 -20
  9. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/configdocs.py +23 -6
  10. dirigent_core-0.28.0/src/dirigent_core/connections.py +104 -0
  11. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/database.py +34 -1
  12. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/directory.py +6 -8
  13. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/documents.py +92 -8
  14. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/claim.py +3 -1
  15. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/context.py +62 -7
  16. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/definition.py +133 -7
  17. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/executor.py +153 -79
  18. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/failure.py +23 -7
  19. dirigent_core-0.28.0/src/dirigent_core/engine/quota.py +186 -0
  20. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/recovery.py +1 -0
  21. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/references.py +248 -49
  22. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/replies.py +42 -5
  23. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/runs.py +154 -3
  24. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/services.py +2 -2
  25. dirigent_core-0.28.0/src/dirigent_core/engine/shell.py +802 -0
  26. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/messages.py +181 -4
  27. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/models.py +56 -18
  28. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/pipelines.py +38 -17
  29. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/ratelimit.py +17 -4
  30. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/scheduler.py +43 -23
  31. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/schemas.py +12 -2
  32. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/secrets.py +96 -17
  33. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/endpoint.py +64 -3
  34. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/holding.py +25 -11
  35. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/intake.py +156 -38
  36. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/polling.py +29 -8
  37. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/rows.py +40 -7
  38. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/rules.py +19 -3
  39. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/waiter.py +0 -2
  40. dirigent_core-0.28.0/src/dirigent_core/statedir.py +296 -0
  41. dirigent_core-0.28.0/src/dirigent_core/stats.py +223 -0
  42. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/storage.py +160 -28
  43. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/trigger_documents.py +10 -4
  44. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/materialize.py +15 -0
  45. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/schedules.py +8 -2
  46. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/types.py +44 -0
  47. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/LICENSE +0 -0
  48. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/README.md +0 -0
  49. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/__init__.py +0 -0
  50. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alembic/env.py +0 -0
  51. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/alembic/script.py.mako +0 -0
  52. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/blockdocs.py +0 -0
  53. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/documentschema.py +0 -0
  54. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/__init__.py +0 -0
  55. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/expansion.py +0 -0
  56. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/engine/state.py +0 -0
  57. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/errors.py +0 -0
  58. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/examples.py +0 -0
  59. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/ids.py +0 -0
  60. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/logging.py +0 -0
  61. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/migrations.py +0 -0
  62. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/plugins.py +0 -0
  63. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/protocol.py +0 -0
  64. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/py.typed +0 -0
  65. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/registry.py +0 -0
  66. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/reporting.py +0 -0
  67. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/retention.py +0 -0
  68. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/__init__.py +0 -0
  69. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/entries.py +0 -0
  70. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/kinds.py +0 -0
  71. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/sensor.py +0 -0
  72. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/sources/storage.py +0 -0
  73. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/telemetry.py +0 -0
  74. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/__init__.py +0 -0
  75. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/triggers/backfill.py +0 -0
  76. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/wakeups.py +0 -0
  77. {dirigent_core-0.27.0 → dirigent_core-0.28.0}/src/dirigent_core/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-core
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: Dirigent engine core: schema, configuration, and plugin host.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -12,12 +12,13 @@ Requires-Dist: argon2-cffi>=25.1.0
12
12
  Requires-Dist: asyncpg>=0.31.0
13
13
  Requires-Dist: cronsim>=2.7
14
14
  Requires-Dist: cryptography>=46.0.5
15
- Requires-Dist: dirigent-client==0.27.0
16
- Requires-Dist: dirigent-common==0.27.0
17
- Requires-Dist: dirigent-plugin==0.27.0
15
+ Requires-Dist: dirigent-client==0.28.0
16
+ Requires-Dist: dirigent-common==0.28.0
17
+ Requires-Dist: dirigent-plugin==0.28.0
18
18
  Requires-Dist: jsonschema>=4.26.0
19
19
  Requires-Dist: opentelemetry-api>=1.44.0
20
20
  Requires-Dist: pydantic-settings>=2.15.0
21
+ Requires-Dist: python-dotenv>=1.1.0
21
22
  Requires-Dist: pyyaml>=6.0.3
22
23
  Requires-Dist: sqlalchemy[asyncio]>=2.1.3
23
24
  Requires-Dist: structlog>=25.5.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.27.0"
3
+ version = "0.28.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,12 +17,13 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.27.0",
21
- "dirigent-common==0.27.0",
22
- "dirigent-plugin==0.27.0",
20
+ "dirigent-client==0.28.0",
21
+ "dirigent-common==0.28.0",
22
+ "dirigent-plugin==0.28.0",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
26
+ "python-dotenv>=1.1.0",
26
27
  "pyyaml>=6.0.3",
27
28
  "sqlalchemy[asyncio]>=2.1.3",
28
29
  "structlog>=25.5.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.27.0"
3
+ version = "0.28.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,12 +17,13 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.27.0",
21
- "dirigent-common==0.27.0",
22
- "dirigent-plugin==0.27.0",
20
+ "dirigent-client==0.28.0",
21
+ "dirigent-common==0.28.0",
22
+ "dirigent-plugin==0.28.0",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
26
+ "python-dotenv>=1.1.0",
26
27
  "pyyaml>=6.0.3",
27
28
  "sqlalchemy[asyncio]>=2.1.3",
28
29
  "structlog>=25.5.0",
@@ -450,6 +450,7 @@ def upgrade() -> None:
450
450
  sa.Column("token_hash", sa.String(length=128), nullable=True),
451
451
  sa.Column("token_prefix", sa.String(length=16), nullable=True),
452
452
  sa.Column("signing_secret", sa.LargeBinary(), nullable=True),
453
+ sa.Column("secret_fingerprint", sa.String(length=16), nullable=True),
453
454
  sa.Column("signing_key_id", sa.String(length=64), nullable=True),
454
455
  sa.Column("paused", sa.Boolean(), server_default=sa.false(), nullable=False),
455
456
  sa.Column("accepted", sa.Integer(), server_default=sa.text("0"), nullable=False),
@@ -524,6 +525,7 @@ def upgrade() -> None:
524
525
  sa.Enum(
525
526
  "queued",
526
527
  "running",
528
+ "paused",
527
529
  "succeeded",
528
530
  "completed_with_errors",
529
531
  "failed",
@@ -550,6 +552,8 @@ def upgrade() -> None:
550
552
  "user",
551
553
  "pipeline",
552
554
  "backfill",
555
+ "source",
556
+ "rerun",
553
557
  name="trigger_kind",
554
558
  native_enum=False,
555
559
  length=32,
@@ -567,6 +571,7 @@ def upgrade() -> None:
567
571
  ),
568
572
  sa.Column("started_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
569
573
  sa.Column("finished_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
574
+ sa.Column("paused_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
570
575
  sa.Column("window_start", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
571
576
  sa.Column("window_end", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
572
577
  sa.Column(
@@ -578,6 +583,18 @@ def upgrade() -> None:
578
583
  sa.Column(
579
584
  "schemas", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=False
580
585
  ),
586
+ sa.Column(
587
+ "stored_bytes",
588
+ sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
589
+ server_default=sa.text("0"),
590
+ nullable=False,
591
+ ),
592
+ sa.Column(
593
+ "chain_stored_bytes",
594
+ sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
595
+ server_default=sa.text("0"),
596
+ nullable=False,
597
+ ),
581
598
  sa.Column(
582
599
  "created_at",
583
600
  dirigent_core.types.UtcDateTime(timezone=True),
@@ -808,6 +825,19 @@ def upgrade() -> None:
808
825
  batch_op.create_index("ix_source_inputs_created_at", ["created_at"], unique=False)
809
826
  batch_op.create_index("ix_source_inputs_source_id_id", ["source_id", "id"], unique=False)
810
827
 
828
+ op.create_table(
829
+ "source_signatures",
830
+ sa.Column("source_id", sa.Uuid(), nullable=False),
831
+ sa.Column("signature", sa.String(length=64), nullable=False),
832
+ sa.Column("signed_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=False),
833
+ sa.ForeignKeyConstraint(
834
+ ["source_id"], ["sources.id"], name=op.f("fk_source_signatures_source_id_sources"), ondelete="CASCADE"
835
+ ),
836
+ sa.PrimaryKeyConstraint("source_id", "signature", name=op.f("pk_source_signatures")),
837
+ )
838
+ with op.batch_alter_table("source_signatures", schema=None) as batch_op:
839
+ batch_op.create_index("ix_source_signatures_signed_at", ["signed_at"], unique=False)
840
+
811
841
  op.create_table(
812
842
  "step_attempts",
813
843
  sa.Column("id", sa.Uuid(), nullable=False),
@@ -843,6 +873,7 @@ def upgrade() -> None:
843
873
  sa.Column("lease_token", sa.Uuid(), nullable=True),
844
874
  sa.Column("lease_expires_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
845
875
  sa.Column("heartbeat_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
876
+ sa.Column("lease_recoveries", sa.Integer(), server_default=sa.text("0"), nullable=False),
846
877
  sa.Column(
847
878
  "remote_handle", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
848
879
  ),
@@ -999,6 +1030,10 @@ def downgrade() -> None:
999
1030
  batch_op.drop_index("ix_step_attempts_lease_expires_at")
1000
1031
 
1001
1032
  op.drop_table("step_attempts")
1033
+ with op.batch_alter_table("source_signatures", schema=None) as batch_op:
1034
+ batch_op.drop_index("ix_source_signatures_signed_at")
1035
+
1036
+ op.drop_table("source_signatures")
1002
1037
  with op.batch_alter_table("source_inputs", schema=None) as batch_op:
1003
1038
  batch_op.drop_index("ix_source_inputs_source_id_id")
1004
1039
  batch_op.drop_index("ix_source_inputs_created_at")
@@ -35,6 +35,8 @@ from dirigent_common import (
35
35
  compile_template,
36
36
  format_duration,
37
37
  render,
38
+ scrub,
39
+ secrets_of,
38
40
  )
39
41
  from dirigent_core.database import is_deadlock, session_scope
40
42
  from dirigent_core.engine.definition import load_definition
@@ -786,18 +788,20 @@ async def send_notification(
786
788
  has to be held anyway, because the fence below re-reads the row it writes.
787
789
  """
788
790
  moment = now or utcnow()
791
+ secrets: set[str] = set()
789
792
  try:
790
793
  notifier = services.host.notifiers.get(notification.notifier)
791
794
  if notifier is None:
792
795
  raise AlertError(NOTIFIER_NOT_ON_WORKER, notifier=repr(notification.notifier))
793
796
  connection = await session.get(Connection, notification.connection_id) if notification.connection_id else None
794
797
  config = notifier_config(services, notifier, connection)
798
+ secrets = secrets_of(config)
795
799
  await session.commit()
796
800
  await notifier.send(_message(notification), config)
797
801
  except Exception as error:
798
802
  if not await _holds_lease(session, notification, owner, lease_token):
799
803
  return False
800
- return await _record_failure(session, services, notification, error, moment)
804
+ return await _record_failure(session, services, notification, error, moment, secrets)
801
805
  if not await _holds_lease(session, notification, owner, lease_token):
802
806
  return False
803
807
  notification.status = NotificationStatus.SENT
@@ -896,9 +900,10 @@ async def _record_failure(
896
900
  notification: Notification,
897
901
  error: Exception,
898
902
  moment: datetime,
903
+ secrets: set[str],
899
904
  ) -> bool:
900
905
  """Schedule another delivery, or record that this one is never going to arrive."""
901
- message = f"{type(error).__name__}: {error}"
906
+ message = scrub(f"{type(error).__name__}: {error}", secrets)
902
907
  notification.error = message
903
908
  notification.lease_owner = None
904
909
  notification.lease_token = None
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
11
11
 
12
12
  from dirigent_common import JsonMap
13
13
  from dirigent_core.errors import DomainError
14
- from dirigent_core.messages import OBJECT_MISSING
14
+ from dirigent_core.messages import OBJECT_ALTERED, OBJECT_MISSING
15
15
  from dirigent_core.models import ArtifactRef, StepAttempt
16
16
  from dirigent_core.storage import Storage, StorageError, join_uri, parse_uri
17
17
 
@@ -41,6 +41,21 @@ class ObjectMissing(DomainError):
41
41
  )
42
42
 
43
43
 
44
+ class ArtifactAltered(DomainError):
45
+ """The object in storage is not the one this row recorded when it was written."""
46
+
47
+ status = 500
48
+ message = OBJECT_ALTERED
49
+
50
+ def __init__(self, reference: ArtifactRef) -> None:
51
+ """Name the object, and the run and attempt whose row recorded something else."""
52
+ super().__init__(
53
+ uri=reference.uri or "",
54
+ run=str(reference.run_id),
55
+ attempt=str(reference.step_attempt_id) if reference.step_attempt_id else NO_ATTEMPT,
56
+ )
57
+
58
+
44
59
  def canonical_json(value: object) -> bytes:
45
60
  """Serialize a value the one way the engine hashes and stores it."""
46
61
  return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
@@ -137,14 +152,39 @@ async def _present_uri(storage: Storage, reference: ArtifactRef) -> str:
137
152
  where one that opened the stream first would break mid-body with the status already sent.
138
153
  """
139
154
  uri = reference.uri or ""
140
- if await storage.stat(uri) is None:
155
+ found = await storage.stat(uri)
156
+ if found is None:
141
157
  raise ObjectMissing(reference)
158
+ if reference.size_bytes is not None and found.size != reference.size_bytes:
159
+ raise ArtifactAltered(reference)
142
160
  return uri
143
161
 
144
162
 
163
+ async def _verified(chunks: AsyncGenerator[bytes], reference: ArtifactRef) -> AsyncGenerator[bytes]:
164
+ """Pass a stored object through, and fail at its end if it is not what the row recorded.
165
+
166
+ A stream's status has gone by then, so what this can do is end the response as an error
167
+ rather than a complete body. A row with no digest has nothing to check against.
168
+ """
169
+ hashed = hashlib.sha256()
170
+ async for chunk in chunks:
171
+ hashed.update(chunk)
172
+ yield chunk
173
+ if reference.digest is not None and f"sha256:{hashed.hexdigest()}" != reference.digest:
174
+ raise ArtifactAltered(reference)
175
+
176
+
177
+ async def _read_verified(storage: Storage, reference: ArtifactRef) -> bytes:
178
+ """Read a whole stored object, refusing one that is not what the row recorded."""
179
+ payload = await storage.read_bytes(await _present_uri(storage, reference))
180
+ if reference.digest is not None and digest_of(payload) != reference.digest:
181
+ raise ArtifactAltered(reference)
182
+ return payload
183
+
184
+
145
185
  async def open_artifact(storage: Storage, reference: ArtifactRef) -> AsyncGenerator[bytes]:
146
- """Open the stored object a row names for streaming, refusing before the first byte."""
147
- return storage.open_read(await _present_uri(storage, reference))
186
+ """Open the stored object a row names for streaming, refusing what it can before the first byte."""
187
+ return _verified(storage.open_read(await _present_uri(storage, reference)), reference)
148
188
 
149
189
 
150
190
  async def load_document(storage: Storage, reference: ArtifactRef) -> str:
@@ -154,7 +194,7 @@ async def load_document(storage: Storage, reference: ArtifactRef) -> str:
154
194
  return inlined if isinstance(inlined, str) else ""
155
195
  if reference.uri is None:
156
196
  return ""
157
- return (await storage.read_bytes(await _present_uri(storage, reference))).decode()
197
+ return (await _read_verified(storage, reference)).decode()
158
198
 
159
199
 
160
200
  async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UUID) -> JsonMap | None:
@@ -166,6 +206,6 @@ async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UU
166
206
  return reference.inline_value
167
207
  if reference.uri is None:
168
208
  return None
169
- payload = await storage.read_bytes(await _present_uri(storage, reference))
209
+ payload = await _read_verified(storage, reference)
170
210
  loaded: JsonMap = json.loads(payload)
171
211
  return loaded
@@ -251,6 +251,15 @@ async def create_user(
251
251
  return user
252
252
 
253
253
 
254
+ async def create_fixture_admin(session: AsyncSession, username: str, password: str) -> User:
255
+ """Create the admin of a throwaway instance, whose fixed password need not meet the length rule."""
256
+ user = User(username=username, password_hash=_hasher.hash(password), role=UserRole.ADMIN)
257
+ session.add(user)
258
+ await session.flush()
259
+ _logger.info("fixture admin created", username=username)
260
+ return user
261
+
262
+
254
263
  async def reset_password(session: AsyncSession, user: User, password: str) -> User:
255
264
  """Replace an account's password without its old one, ending every session it holds and no API token."""
256
265
  user.password_hash = hash_password(password)
@@ -467,6 +476,28 @@ async def resolve_token(session: AsyncSession, secret: str, *, now: datetime | N
467
476
  )
468
477
 
469
478
 
479
+ async def principal_stands(session: AsyncSession, principal: Principal, *, now: datetime | None = None) -> bool:
480
+ """Say whether what authenticated a principal would still authenticate it, in one indexed read.
481
+
482
+ What a long-lived response asks on every poll, inside that poll's own transaction, so the
483
+ credential it was opened with is held to the same rule a new request is.
484
+ """
485
+ moment = now or utcnow()
486
+ if principal.token_id is None:
487
+ statement = sa.select(User.active).where(User.id == principal.user_id)
488
+ else:
489
+ statement = (
490
+ sa.select(User.active)
491
+ .join(ApiToken, ApiToken.user_id == User.id)
492
+ .where(
493
+ ApiToken.id == principal.token_id,
494
+ ApiToken.revoked_at.is_(None),
495
+ sa.or_(ApiToken.expires_at.is_(None), ApiToken.expires_at > moment),
496
+ )
497
+ )
498
+ return (await session.execute(statement)).scalar_one_or_none() is True
499
+
500
+
470
501
  async def list_tokens(
471
502
  session: AsyncSession,
472
503
  *,
@@ -503,23 +534,22 @@ async def list_tokens(
503
534
  ]
504
535
 
505
536
 
506
- async def revoke_token(session: AsyncSession, *, user_id: UUID, name: str) -> bool:
507
- """Revoke one account's live tokens of a given name, reporting whether anything was revoked.
537
+ async def revoke_token(session: AsyncSession, token_id: UUID) -> bool:
538
+ """Revoke one live API token by its id, reporting whether there was one to revoke.
508
539
 
509
- A name is unique only within an account, so the owner is part of what is revoked.
540
+ A browser session shares the table and is not reached from here.
510
541
  """
511
542
  statement = sa.select(ApiToken).where(
543
+ ApiToken.id == token_id,
544
+ ApiToken.kind == TokenKind.API,
512
545
  ApiToken.revoked_at.is_(None),
513
- ApiToken.user_id == user_id,
514
- ApiToken.name == name,
515
546
  )
516
- rows = await session.execute(statement)
517
- revoked = 0
518
- for token in rows.scalars():
519
- token.revoked_at = utcnow()
520
- revoked += 1
547
+ token = (await session.execute(statement)).scalar_one_or_none()
548
+ if token is None:
549
+ return False
550
+ token.revoked_at = utcnow()
521
551
  await session.flush()
522
- return revoked > 0
552
+ return True
523
553
 
524
554
 
525
555
  async def revoke_session(session: AsyncSession, secret: str) -> None: