samagotchi 0.152.0 → 0.154.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundle-authoring.md +1 -1
- data/docs/bundles.md +93 -12
- data/docs/cli.md +83 -99
- data/docs/configuration.md +46 -40
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +15 -15
- data/docs/hooks.md +13 -12
- data/docs/internals/anthropic.md +3 -3
- data/docs/internals/attached-context.md +5 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +3 -3
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +42 -12
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +8 -8
- data/docs/internals/prompt-caching.md +8 -4
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-frontend.md +20 -17
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +52 -43
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +25 -25
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +18 -8
- data/lib/samagotchi/answer_text.rb +34 -0
- data/lib/samagotchi/bridge.rb +43 -32
- data/lib/samagotchi/bridge_client.rb +10 -9
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
- data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +63 -2642
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +4 -5
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +10 -11
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/message_parts.rb +3 -2
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +59 -36
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +3 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +102 -109
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +14 -16
- data/lib/samagotchi/toolkit.rb +8 -5
- data/lib/samagotchi/toolkit_binding.rb +30 -5
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/toolkit_report_command.rb +9 -5
- data/lib/samagotchi/toolkit_suggest.rb +12 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -10
- data/lib/samagotchi/tools/deferred_tools.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/registry.rb +44 -20
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/tools/tool_index.rb +27 -21
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +4 -5
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +39 -50
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -357
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +110 -0
- data/lib/samagotchi/web/public/format.js +3 -3
- data/lib/samagotchi/web/public/owner_claim.js +81 -0
- data/lib/samagotchi/web/public/owner_verify.js +112 -0
- data/lib/samagotchi/web/public/session_fields.js +3 -3
- data/lib/samagotchi/web/public/strip_grip.js +3 -3
- data/lib/samagotchi/web/public/strip_hidden.js +65 -0
- data/lib/samagotchi/web/public/version_notices.js +132 -0
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +106 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- metadata +36 -6
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
|
@@ -1,575 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "json"
|
|
6
|
-
require_relative "../steer"
|
|
7
|
-
require_relative "../participant_mode"
|
|
8
|
-
require "securerandom"
|
|
9
|
-
require "time"
|
|
10
|
-
require "rack/utils"
|
|
11
|
-
require_relative "../atomic_file"
|
|
12
|
-
require_relative "../config"
|
|
13
|
-
require_relative "../log"
|
|
14
|
-
require_relative "../participant_name"
|
|
15
|
-
require_relative "../session"
|
|
16
|
-
require_relative "../redacted_inspect"
|
|
17
|
-
require_relative "private_files"
|
|
18
|
-
require_relative "participant_exposure"
|
|
19
|
-
|
|
20
|
-
module Samagotchi
|
|
21
|
-
module Web
|
|
22
|
-
# Participants' access to one session each (the shared-sessions plan,
|
|
23
|
-
# security model §1): a grant binds one random token to one session, one
|
|
24
|
-
# participant and one role, until it expires or is revoked. Only the
|
|
25
|
-
# token's SHA-256 digest is kept, in
|
|
26
|
-
# $XDG_STATE_HOME/samagotchi/web-grants/grants.json (0600, in its own 0700
|
|
27
|
-
# folder): the token is shown once, when it is issued, and a leaked file
|
|
28
|
-
# grants nothing. The owner's Web::Token is a different credential and
|
|
29
|
-
# never touches this file. See docs/internals/participants.md.
|
|
30
|
-
#
|
|
31
|
-
# One object per process (chi web, a chi share run): writes take a lock
|
|
32
|
-
# file next to the store, so the two never lose each other's grants; the
|
|
33
|
-
# reads follow the file's mtime, as Token::Source does.
|
|
34
|
-
class Grants
|
|
35
|
-
include RedactedInspect
|
|
36
|
-
|
|
37
|
-
DIR = "web-grants"
|
|
38
|
-
FILE = "grants.json"
|
|
39
|
-
LOCK = "grants.lock"
|
|
40
|
-
# What a participant token starts with: the gate looks up only these,
|
|
41
|
-
# and a leaked one is recognisable (an owner token never has it).
|
|
42
|
-
PREFIX = "chp_"
|
|
43
|
-
# 32 random bytes, as Web::Token's.
|
|
44
|
-
BYTES = 32
|
|
45
|
-
DAY = 86_400
|
|
46
|
-
# D8: a grant lasts a week unless chi share --expires says otherwise,
|
|
47
|
-
# and never more than MAX_TTL.
|
|
48
|
-
DEFAULT_TTL = 7 * DAY
|
|
49
|
-
MAX_TTL = 30 * DAY
|
|
50
|
-
# Ended grants (revoked, expired) are kept this long, so their token
|
|
51
|
-
# answers token_revoked / token_expired rather than a bare 401.
|
|
52
|
-
KEEP_ENDED = 30 * DAY
|
|
53
|
-
# last_used_at is written at most this often per grant.
|
|
54
|
-
TOUCH_INTERVAL = 60
|
|
55
|
-
KINDS = %w[human agent].freeze
|
|
56
|
-
# The only role in v1: a reviewer reads and (P3) comments.
|
|
57
|
-
ROLES = %w[reviewer].freeze
|
|
58
|
-
# The participant mode a grant was issued for (ParticipantMode): "pair"
|
|
59
|
-
# only when the owner was shown that a participant's turns act
|
|
60
|
-
# (ReviewStart::PAIR_WARNING); none for every other grant, and every
|
|
61
|
-
# grant from before it, whose turns stay review turns.
|
|
62
|
-
PAIR_MODE = ParticipantMode::PAIR.to_s
|
|
63
|
-
NAME_MAX = ParticipantName::MAX
|
|
64
|
-
ID_RE = /\Ap_[0-9a-f]{8}\z/
|
|
65
|
-
# D7: what a participant's turns (and the side calls they cause) may
|
|
66
|
-
# cost the owner, in dollars, unless chi share --cap says otherwise
|
|
67
|
-
# (config web.participants.cap); at most MAX_CAP.
|
|
68
|
-
DEFAULT_CAP = 2.0
|
|
69
|
-
MAX_CAP = 1000.0
|
|
70
|
-
# How many of a participant's turns may wait behind their running one
|
|
71
|
-
# (config web.participants.queue); at most MAX_QUEUE.
|
|
72
|
-
DEFAULT_QUEUE = 3
|
|
73
|
-
MAX_QUEUE = 20
|
|
74
|
-
# chi share --expires and config web.participants.expires: Nh or Nd.
|
|
75
|
-
EXPIRES_RE = /\A(\d+)([hd])\z/
|
|
76
|
-
|
|
77
|
-
# The store's folder or file can be read by others (chi web refuses to
|
|
78
|
-
# start), or a grant can't be made from what was given.
|
|
79
|
-
class Insecure < StandardError; end
|
|
80
|
-
# Not ArgumentError: the session commands read that as "no such
|
|
81
|
-
# session".
|
|
82
|
-
class Invalid < StandardError; end
|
|
83
|
-
|
|
84
|
-
# An identity to bind a grant to that isn't one email address.
|
|
85
|
-
class BadIdentity < Invalid
|
|
86
|
-
def initialize(msg = "a link is bound to one email address (name@domain: one @, no spaces or commas, ASCII " \
|
|
87
|
-
"only)") = super
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# An identity asked of a chi web that can't check one: no auth proxy
|
|
91
|
-
# naming the signed-in user (ProxyGate), so the link would never open.
|
|
92
|
-
class Unbindable < Invalid
|
|
93
|
-
def initialize(msg = "a link can be bound to an email only on a chi web behind an auth proxy that names the " \
|
|
94
|
-
"signed-in user (web.public_url with web.proxy.identity_header, or chi web --tunnel " \
|
|
95
|
-
"tailscale): bound here it would never open") = super
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# The store's version: 1, and BOUND_VERSION once a grant is bound to
|
|
99
|
-
# an identity, so a chi that knows only 1 and checks (from this one
|
|
100
|
-
# on) doesn't rewrite it. MAX_VERSION is the newest this chi reads
|
|
101
|
-
# and writes; a newer store it reads but never writes (Newer).
|
|
102
|
-
VERSION = 1
|
|
103
|
-
BOUND_VERSION = 2
|
|
104
|
-
MAX_VERSION = 2
|
|
105
|
-
|
|
106
|
-
# A store chi won't write over (Corrupt, Newer): the owner's invite,
|
|
107
|
-
# cap and revoke are refused with its message.
|
|
108
|
-
class Unwritable < StandardError; end
|
|
109
|
-
|
|
110
|
-
# The store is there but doesn't read (not JSON, a grant without its
|
|
111
|
-
# digest or expiry): its grants find nothing (fail closed), and
|
|
112
|
-
# nothing writes over it, which would lose every other grant.
|
|
113
|
-
class Corrupt < Unwritable
|
|
114
|
-
def initialize(path, reason)
|
|
115
|
-
super("#{path} can't be read (#{reason}): chi won't write over it; fix it, or move it aside to end " \
|
|
116
|
-
"every participant's link")
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# The store was written by a newer chi (its version above
|
|
121
|
-
# MAX_VERSION): its grants still work here (read as this chi knows
|
|
122
|
-
# them), but nothing writes over it, which would drop what that chi
|
|
123
|
-
# added to them.
|
|
124
|
-
class Newer < Unwritable
|
|
125
|
-
def initialize(path, version)
|
|
126
|
-
super("#{path} was written by a newer chi (grants store version #{version}; this chi knows up to " \
|
|
127
|
-
"#{MAX_VERSION}): chi won't write over it, which would drop what that chi added; update chi " \
|
|
128
|
-
"(chi update)")
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# One participant's access to one session. +id+ (p_<8 hex>) names the
|
|
133
|
-
# participant too: a turn they send is authored "p:<id>" (P3).
|
|
134
|
-
# +revoked_by+ why it was revoked: "owner", "archive", "delete",
|
|
135
|
-
# "gone" (the session was pruned).
|
|
136
|
-
# +cap+ (dollars) and +queue+ (turns waiting) as issued; nil in a
|
|
137
|
-
# grant from before them, which takes the config's (#spend_cap,
|
|
138
|
-
# #queue_limit).
|
|
139
|
-
# +exposure+: what the participant learns of the review's usage
|
|
140
|
-
# (ParticipantExposure; "own" for every grant today, and for one from
|
|
141
|
-
# before it). Kept as stored: a level this chi doesn't know reads as
|
|
142
|
-
# the narrowest, and is written back as it was.
|
|
143
|
-
# +extra+: the stored keys this chi doesn't know (a newer chi's,
|
|
144
|
-
# such as a field added to a grant without a version bump), written
|
|
145
|
-
# back as they were, so a rewrite here never drops them.
|
|
146
|
-
# +mode+: PAIR_MODE when the owner invited them to act (Grants#issue's
|
|
147
|
-
# mode:), nil otherwise. Stored only when set; a chi that doesn't know
|
|
148
|
-
# it keeps it in +extra+ and runs their turns as review turns.
|
|
149
|
-
Grant = Data.define(:id, :session_id, :digest, :kind, :name, :role, :created_at, :expires_at, :revoked_at,
|
|
150
|
-
:revoked_by, :last_used_at, :cap, :queue, :identity, :exposure, :mode, :extra) do
|
|
151
|
-
# identity: the proxy identity (an email, lower case) the grant is
|
|
152
|
-
# bound to, nil for any (ProxyGate; issued from shared-sessions P6).
|
|
153
|
-
def initialize(cap: nil, queue: nil, identity: nil, exposure: nil, mode: nil, extra: {}, **)
|
|
154
|
-
super(cap: cap, queue: queue, identity: identity, extra: extra, mode: mode,
|
|
155
|
-
exposure: exposure.nil? ? ParticipantExposure::DEFAULT : exposure, **)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def self.from_h(hash)
|
|
159
|
-
known = members - [:extra]
|
|
160
|
-
extra = hash.reject { |key, _| known.include?(key.to_s.to_sym) }
|
|
161
|
-
new(**known.to_h { |key| [key, hash[key.to_s]] }, extra: extra)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# The dollars this participant may cost the owner.
|
|
165
|
-
def spend_cap = cap.is_a?(Numeric) ? cap.to_f : Grants.default_cap
|
|
166
|
-
|
|
167
|
-
# How many of their turns may wait behind their running one.
|
|
168
|
-
def queue_limit = queue.is_a?(Integer) ? queue : Grants.default_queue
|
|
169
|
-
|
|
170
|
-
# The author a participant's turn carries (Principal#author).
|
|
171
|
-
def author = Steer.author_for(id)
|
|
172
|
-
|
|
173
|
-
# :active, :revoked or :expired at +now+ (a Time).
|
|
174
|
-
def state(now)
|
|
175
|
-
return :revoked if revoked_at
|
|
176
|
-
return :expired if Time.iso8601(expires_at) <= now
|
|
177
|
-
|
|
178
|
-
:active
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def active?(now) = state(now) == :active
|
|
182
|
-
|
|
183
|
-
# When it stopped working (revoked or expired), nil while active.
|
|
184
|
-
def ended_at(now)
|
|
185
|
-
return Time.iso8601(revoked_at) if revoked_at
|
|
186
|
-
|
|
187
|
-
expiry = Time.iso8601(expires_at)
|
|
188
|
-
expiry <= now ? expiry : nil
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Never the digest: what chi share ls and the owner's routes show.
|
|
192
|
-
def public_h(now)
|
|
193
|
-
{ id: id, session_id: session_id, kind: kind, name: name, role: role, created_at: created_at,
|
|
194
|
-
expires_at: expires_at, revoked_at: revoked_at, revoked_by: revoked_by, last_used_at: last_used_at,
|
|
195
|
-
state: state(now).to_s, cap: spend_cap, queue: queue_limit, identity: identity }
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# A grant bound to no identity, with the default exposure, is stored
|
|
199
|
-
# as before either existed; the keys this chi doesn't know come
|
|
200
|
-
# after, as they were.
|
|
201
|
-
#
|
|
202
|
-
# A non-default exposure needs no version bump (identity's
|
|
203
|
-
# BOUND_VERSION): a chi that ignores it shows the participant the
|
|
204
|
-
# narrowest level, so it fails closed, and one that knows Grant#extra
|
|
205
|
-
# keeps it on a rewrite. A bump would only make that chi refuse the
|
|
206
|
-
# owner's invite, cap and revoke (Newer) for no safety gain.
|
|
207
|
-
def to_store
|
|
208
|
-
hash = to_h.except(:extra).transform_keys(&:to_s)
|
|
209
|
-
hash.delete("identity") if identity.nil?
|
|
210
|
-
hash.delete("mode") if mode.nil?
|
|
211
|
-
hash.delete("exposure") if exposure == ParticipantExposure::DEFAULT
|
|
212
|
-
hash.merge(extra.reject { |key, _| hash.key?(key) })
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def inspect = "#<Grant #{id} #{session_id[0, 8]} #{name.inspect}>"
|
|
216
|
-
alias_method :to_s, :inspect
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# A new grant and its token: the only time the token exists outside
|
|
220
|
-
# the participant's hands.
|
|
221
|
-
Issued = Data.define(:grant, :token) do
|
|
222
|
-
def inspect = "#<Issued #{grant.id}>"
|
|
223
|
-
alias_method :to_s, :inspect
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
# $XDG_STATE_HOME/samagotchi/web-grants/grants.json, next to web-token;
|
|
227
|
-
# +state_dir+ is the sessions dir (Session.default_state_dir).
|
|
228
|
-
def self.path(state_dir: nil, env: ENV)
|
|
229
|
-
File.join(File.dirname(state_dir || Session.default_state_dir(env: env)), DIR, FILE)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def self.digest(token) = Digest::SHA256.hexdigest(token.to_s)
|
|
233
|
-
|
|
234
|
-
# Seconds for +words+ (Nh or Nd, 1 hour to MAX_TTL), nil for none,
|
|
235
|
-
# false for words that aren't that.
|
|
236
|
-
def self.parse_ttl(words)
|
|
237
|
-
return nil if words.nil?
|
|
238
|
-
|
|
239
|
-
match = EXPIRES_RE.match(words.to_s.strip) or return false
|
|
240
|
-
seconds = match[1].to_i * (match[2] == "h" ? 3600 : DAY)
|
|
241
|
-
seconds.between?(3600, MAX_TTL) ? seconds : false
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# A new grant's lifetime: config web.participants.expires, else
|
|
245
|
-
# DEFAULT_TTL (a value that isn't Nh/Nd in range too).
|
|
246
|
-
def self.default_ttl
|
|
247
|
-
parse_ttl(Config.get("web.participants.expires")) || DEFAULT_TTL
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# A grant's cap without its own: config web.participants.cap, else
|
|
251
|
-
# DEFAULT_CAP (a value out of range too).
|
|
252
|
-
def self.default_cap
|
|
253
|
-
value = Config.get("web.participants.cap")
|
|
254
|
-
cap?(value) ? value.to_f : DEFAULT_CAP
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# A grant's queue limit without its own: config
|
|
258
|
-
# web.participants.queue, else DEFAULT_QUEUE.
|
|
259
|
-
def self.default_queue
|
|
260
|
-
value = Config.get("web.participants.queue")
|
|
261
|
-
value.is_a?(Integer) && value.between?(0, MAX_QUEUE) ? value : DEFAULT_QUEUE
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def self.cap?(value) = value.is_a?(Numeric) && value.to_f.finite? && value.to_f.between?(0, MAX_CAP)
|
|
265
|
-
|
|
266
|
-
def self.token?(value) = value.is_a?(String) && value.start_with?(PREFIX)
|
|
267
|
-
|
|
268
|
-
# What a proxy identity header carries: an email, or a login such as
|
|
269
|
-
# Tailscale's alex@github (one @, a domain without a dot).
|
|
270
|
-
IDENTITY_RE = /\A[^@,<>"\\]+@[^@,<>"\\]+\z/
|
|
271
|
-
# No space or control character anywhere (a header can't carry one,
|
|
272
|
-
# and a name with one could mislead in a listing).
|
|
273
|
-
IDENTITY_BAD_RE = /\p{Space}|\p{Cc}/
|
|
274
|
-
IDENTITY_MAX = 254
|
|
275
|
-
|
|
276
|
-
# +identity+ as the gate compares it (ProxyGate#proxy_identity: strip,
|
|
277
|
-
# lower case).
|
|
278
|
-
# @raise [BadIdentity] not one email address
|
|
279
|
-
def self.clean_identity(identity)
|
|
280
|
-
# ASCII only, as ProxyGate#proxy_identity: its lower case is the same
|
|
281
|
-
# whatever the string's encoding.
|
|
282
|
-
clean = identity.is_a?(String) && identity.ascii_only? ? identity.strip.downcase : ""
|
|
283
|
-
raise BadIdentity unless clean.length <= IDENTITY_MAX && IDENTITY_RE.match?(clean) && !IDENTITY_BAD_RE.match?(clean)
|
|
284
|
-
|
|
285
|
-
clean
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# One line, at most NAME_MAX characters, no brackets (a name can't
|
|
289
|
-
# forge the "[Review contribution from …]" header; ParticipantName).
|
|
290
|
-
# @raise [Invalid] nothing left
|
|
291
|
-
def self.clean_name(name)
|
|
292
|
-
clean = ParticipantName.clean(name)
|
|
293
|
-
raise Invalid, "a participant needs a name" if clean.empty?
|
|
294
|
-
|
|
295
|
-
clean
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Refuses a store others could read: the grants folder must be 0700
|
|
299
|
-
# and the file 0600, both this user's (PrivateFiles).
|
|
300
|
-
# @raise [Insecure]
|
|
301
|
-
def self.check_permissions!(path)
|
|
302
|
-
PrivateFiles.check!(File.dirname(path), [path], holds: "the participants' grants", error: Insecure)
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
attr_reader :path
|
|
306
|
-
|
|
307
|
-
# @param clock [#call] the Time now (specs inject one)
|
|
308
|
-
def initialize(path = Grants.path, clock: -> { Time.now })
|
|
309
|
-
@path = path
|
|
310
|
-
@clock = clock
|
|
311
|
-
@mutex = Mutex.new
|
|
312
|
-
@stamp = nil
|
|
313
|
-
@grants = []
|
|
314
|
-
@corrupt = nil
|
|
315
|
-
@touched = {}
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
def now = @clock.call
|
|
319
|
-
|
|
320
|
-
# A new grant for +session_id+, written before the token is handed out.
|
|
321
|
-
# @param ttl [Integer, nil] seconds (nil: Grants.default_ttl; at most MAX_TTL)
|
|
322
|
-
# @param cap [Numeric, nil] dollars (nil: Grants.default_cap)
|
|
323
|
-
# @param identity [String, nil] the proxy identity (an email) the grant
|
|
324
|
-
# is bound to (Grants.clean_identity), nil for any
|
|
325
|
-
# @param on_write [#call, nil] see #update
|
|
326
|
-
# @return [Issued]
|
|
327
|
-
# @raise [Invalid]
|
|
328
|
-
def issue(session_id:, name:, kind: "human", role: "reviewer", ttl: nil, cap: nil, identity: nil, mode: nil,
|
|
329
|
-
on_write: nil)
|
|
330
|
-
Session.check_id!(session_id)
|
|
331
|
-
ttl ||= Grants.default_ttl
|
|
332
|
-
cap = Grants.default_cap if cap.nil?
|
|
333
|
-
raise Invalid, "kind is human or agent, not #{kind}" unless KINDS.include?(kind)
|
|
334
|
-
raise Invalid, "role is reviewer, not #{role}" unless ROLES.include?(role)
|
|
335
|
-
raise Invalid, "a grant lasts 1 hour to 30 days" unless ttl.is_a?(Integer) && ttl.between?(3600, MAX_TTL)
|
|
336
|
-
raise Invalid, CAP_RANGE unless Grants.cap?(cap)
|
|
337
|
-
raise Invalid, "mode is #{PAIR_MODE} or none, not #{mode}" unless mode.nil? || mode == PAIR_MODE
|
|
338
|
-
|
|
339
|
-
clean = Grants.clean_name(name)
|
|
340
|
-
identity = Grants.clean_identity(identity) unless identity.nil?
|
|
341
|
-
token = "#{PREFIX}#{SecureRandom.urlsafe_base64(BYTES)}"
|
|
342
|
-
issued = now
|
|
343
|
-
update(on_write) do |grants|
|
|
344
|
-
id = fresh_id(grants)
|
|
345
|
-
grant = Grant.new(id: id, session_id: session_id, digest: Grants.digest(token), kind: kind, name: clean,
|
|
346
|
-
role: role, created_at: stamp(issued), expires_at: stamp(issued + ttl), revoked_at: nil,
|
|
347
|
-
revoked_by: nil, last_used_at: nil, cap: cap.to_f, queue: Grants.default_queue,
|
|
348
|
-
identity: identity, mode: mode)
|
|
349
|
-
[grants + [grant], Issued.new(grant: grant, token: token)]
|
|
350
|
-
end
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# The grant +token+ was issued for, in any state (an ended one too, so
|
|
354
|
-
# the gate can say why it's refused); nil for anything else.
|
|
355
|
-
# @return [Grant, nil]
|
|
356
|
-
def lookup(token)
|
|
357
|
-
return nil unless Grants.token?(token)
|
|
358
|
-
|
|
359
|
-
digest = Grants.digest(token)
|
|
360
|
-
# Every grant is compared, whatever matches first.
|
|
361
|
-
current.select { |grant| Rack::Utils.secure_compare(grant.digest, digest) }.first
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
# @return [Grant, nil] the grant with this id
|
|
365
|
-
def find(id) = current.find { |grant| grant.id == id }
|
|
366
|
-
|
|
367
|
-
# Whether grant +id+ still works (a participant stream's running check).
|
|
368
|
-
def active?(id) = !!find(id)&.active?(now)
|
|
369
|
-
|
|
370
|
-
# @param session_id [String, nil] one session's, or every grant
|
|
371
|
-
# @return [Array<Grant>]
|
|
372
|
-
# @raise [Corrupt] (the owner's view: an empty list would hide it)
|
|
373
|
-
def list(session_id: nil)
|
|
374
|
-
current!.select { |grant| session_id.nil? || grant.session_id == session_id }
|
|
375
|
-
end
|
|
376
|
-
|
|
377
|
-
# Revokes the active grants of +session_id+ (just +id+ when given), or
|
|
378
|
-
# every session's when +session_id+ is nil.
|
|
379
|
-
# @param by [String] why: "owner", "archive", "delete", "gone"
|
|
380
|
-
# @param on_write [#call, nil] see #update
|
|
381
|
-
# @return [Array<Grant>] the grants it revoked (already ended ones are
|
|
382
|
-
# left as they were)
|
|
383
|
-
def revoke(session_id: nil, id: nil, by: "owner", on_write: nil)
|
|
384
|
-
at = now
|
|
385
|
-
matches = lambda do |grant|
|
|
386
|
-
grant.active?(at) && (session_id.nil? || grant.session_id == session_id) && (id.nil? || grant.id == id)
|
|
387
|
-
end
|
|
388
|
-
# Nothing to revoke (an archive of a session nobody was invited to):
|
|
389
|
-
# no write, no store made.
|
|
390
|
-
return [] unless current!.any?(&matches)
|
|
391
|
-
|
|
392
|
-
update(on_write) do |grants|
|
|
393
|
-
revoked = []
|
|
394
|
-
grants = grants.map do |grant|
|
|
395
|
-
next grant unless matches.call(grant)
|
|
396
|
-
|
|
397
|
-
grant.with(revoked_at: stamp(at), revoked_by: by).tap { |ended| revoked << ended }
|
|
398
|
-
end
|
|
399
|
-
[grants, revoked]
|
|
400
|
-
end
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
CAP_RANGE = "a cap is $0 to $#{MAX_CAP.to_i}".freeze
|
|
404
|
-
|
|
405
|
-
# Sets the active grant +id+'s cap to +cap+ dollars.
|
|
406
|
-
# @return [Grant, nil] the changed grant; nil when +id+ isn't an
|
|
407
|
-
# active grant (of +session_id+, when given)
|
|
408
|
-
# @param on_write [#call, nil] see #update
|
|
409
|
-
# @raise [Invalid] a cap out of range
|
|
410
|
-
def set_cap(id, cap, session_id: nil, on_write: nil)
|
|
411
|
-
raise Invalid, CAP_RANGE unless Grants.cap?(cap)
|
|
412
|
-
|
|
413
|
-
at = now
|
|
414
|
-
update(on_write) do |grants|
|
|
415
|
-
changed = nil
|
|
416
|
-
grants = grants.map do |grant|
|
|
417
|
-
next grant unless grant.id == id && grant.active?(at) && (session_id.nil? || grant.session_id == session_id)
|
|
418
|
-
|
|
419
|
-
changed = grant.with(cap: cap.to_f)
|
|
420
|
-
end
|
|
421
|
-
[grants, changed]
|
|
422
|
-
end
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
# Records a use of grant +id+, at most once per TOUCH_INTERVAL; not
|
|
426
|
-
# while the store doesn't read (it found no grant then anyway).
|
|
427
|
-
def touch(id)
|
|
428
|
-
at = now
|
|
429
|
-
@mutex.synchronize do
|
|
430
|
-
last = @touched[id]
|
|
431
|
-
return if last && at - last < TOUCH_INTERVAL
|
|
432
|
-
|
|
433
|
-
@touched[id] = at
|
|
434
|
-
end
|
|
435
|
-
update do |grants|
|
|
436
|
-
[grants.map { |grant| grant.id == id ? grant.with(last_used_at: stamp(at)) : grant }, nil]
|
|
437
|
-
end
|
|
438
|
-
rescue Unwritable
|
|
439
|
-
nil
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
private
|
|
443
|
-
|
|
444
|
-
# The grants on disk, re-read when the file changed (one stat a call);
|
|
445
|
-
# none while it doesn't read (logged once per change of the file).
|
|
446
|
-
def current
|
|
447
|
-
stamp = begin
|
|
448
|
-
stat = File.stat(@path)
|
|
449
|
-
[stat.mtime, stat.size, stat.ino]
|
|
450
|
-
rescue Errno::ENOENT
|
|
451
|
-
nil
|
|
452
|
-
end
|
|
453
|
-
@mutex.synchronize do
|
|
454
|
-
unless stamp == @stamp
|
|
455
|
-
reload(stamp)
|
|
456
|
-
@stamp = stamp
|
|
457
|
-
end
|
|
458
|
-
@grants
|
|
459
|
-
end
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
# #current, raising Corrupt for a store that doesn't read.
|
|
463
|
-
def current!
|
|
464
|
-
grants = current
|
|
465
|
-
corrupt = @mutex.synchronize { @corrupt }
|
|
466
|
-
raise corrupt if corrupt
|
|
467
|
-
|
|
468
|
-
grants
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
# In a newer chi's store (Newer) a grant with fields this chi doesn't
|
|
472
|
-
# know is left out: such a field may restrict it (a binding moved to a
|
|
473
|
-
# new key), so it fails closed here. Its link works on that chi again.
|
|
474
|
-
def reload(stamp)
|
|
475
|
-
grants, version = stamp ? read : [[], VERSION]
|
|
476
|
-
grants = grants.select { |grant| grant.extra.empty? } if version > MAX_VERSION
|
|
477
|
-
@grants = grants
|
|
478
|
-
@corrupt = nil
|
|
479
|
-
rescue Corrupt => e
|
|
480
|
-
@grants = []
|
|
481
|
-
@corrupt = e
|
|
482
|
-
Log.warn(:web, "grants_unreadable", echo: "[chi web] #{e.message}", path: @path)
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# [grants, the store's version] (none yet: [[], VERSION]).
|
|
486
|
-
# @raise [Corrupt] the file is there but isn't a grants store
|
|
487
|
-
def read
|
|
488
|
-
data = begin
|
|
489
|
-
JSON.parse(File.read(@path))
|
|
490
|
-
rescue Errno::ENOENT
|
|
491
|
-
return [[], VERSION]
|
|
492
|
-
rescue JSON::ParserError
|
|
493
|
-
raise Corrupt.new(@path, "not JSON")
|
|
494
|
-
end
|
|
495
|
-
rows = data.is_a?(Hash) ? data["grants"] : nil
|
|
496
|
-
raise Corrupt.new(@path, "no grants list") unless rows.is_a?(Array)
|
|
497
|
-
|
|
498
|
-
version = data.fetch("version", VERSION)
|
|
499
|
-
raise Corrupt.new(@path, "a bad version") unless version.is_a?(Integer) && version >= VERSION
|
|
500
|
-
|
|
501
|
-
[rows.each_with_index.map { |hash, index| grant_from(hash, index) }, version]
|
|
502
|
-
end
|
|
503
|
-
|
|
504
|
-
# A stored grant the gate can compare and date, else Corrupt.
|
|
505
|
-
def grant_from(hash, index)
|
|
506
|
-
raise Corrupt.new(@path, "grant #{index + 1} isn't an object") unless hash.is_a?(Hash)
|
|
507
|
-
|
|
508
|
-
grant = Grant.from_h(hash)
|
|
509
|
-
problem = if !grant.id.is_a?(String) || !grant.session_id.is_a?(String) then "no id or session"
|
|
510
|
-
elsif !grant.digest.is_a?(String) || grant.digest.empty? then "no digest"
|
|
511
|
-
elsif !time?(grant.expires_at) then "no expiry"
|
|
512
|
-
elsif !grant.revoked_at.nil? && !time?(grant.revoked_at) then "a bad revoked_at"
|
|
513
|
-
elsif !identity?(grant.identity) then "a bad identity"
|
|
514
|
-
end
|
|
515
|
-
raise Corrupt.new(@path, "grant #{index + 1} has #{problem}") if problem
|
|
516
|
-
|
|
517
|
-
grant
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
# nil (unbound), or an identity as #issue stores it: anything else
|
|
521
|
-
# (false, "", a list) could read as unbound.
|
|
522
|
-
def identity?(value)
|
|
523
|
-
value.nil? || (value.is_a?(String) && Grants.clean_identity(value) == value)
|
|
524
|
-
rescue BadIdentity
|
|
525
|
-
false
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def time?(value)
|
|
529
|
-
value.is_a?(String) && Time.iso8601(value)
|
|
530
|
-
rescue ArgumentError
|
|
531
|
-
false
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# Read, change and write the store under its lock (another process
|
|
535
|
-
# may write it too). The block takes the grants and returns
|
|
536
|
-
# [new grants, result]. Ended grants older than KEEP_ENDED go.
|
|
537
|
-
# +on_write+ is called with (the grants written, result) once the store
|
|
538
|
-
# is written, still under the lock: Participants writes the sidecars
|
|
539
|
-
# there, so two writers can't finish out of order and lose a revoke in
|
|
540
|
-
# one.
|
|
541
|
-
def update(on_write = nil)
|
|
542
|
-
dir = File.dirname(@path)
|
|
543
|
-
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
544
|
-
File.chmod(0o700, dir)
|
|
545
|
-
File.open(File.join(dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
546
|
-
lock.flock(File::LOCK_EX)
|
|
547
|
-
# Never over a store that didn't read (Corrupt): the next write
|
|
548
|
-
# would keep only this one's change. Nor over a newer chi's
|
|
549
|
-
# (Newer): this chi would drop what it doesn't know.
|
|
550
|
-
stored, version = read
|
|
551
|
-
raise Newer.new(@path, version) if version > MAX_VERSION
|
|
552
|
-
|
|
553
|
-
grants, result = yield(stored)
|
|
554
|
-
at = now
|
|
555
|
-
kept = grants.reject { |grant| (ended = grant.ended_at(at)) && at - ended > KEEP_ENDED }
|
|
556
|
-
version = [version, kept.any?(&:identity) ? BOUND_VERSION : VERSION].max
|
|
557
|
-
body = JSON.pretty_generate({ "version" => version, "grants" => kept.map(&:to_store) })
|
|
558
|
-
AtomicFile.write(@path, "#{body}\n", perm: 0o600)
|
|
559
|
-
@mutex.synchronize { @stamp = nil }
|
|
560
|
-
on_write&.call(kept, result)
|
|
561
|
-
result
|
|
562
|
-
end
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
def fresh_id(grants)
|
|
566
|
-
loop do
|
|
567
|
-
id = "p_#{SecureRandom.hex(4)}"
|
|
568
|
-
return id unless grants.any? { |grant| grant.id == id }
|
|
569
|
-
end
|
|
570
|
-
end
|
|
571
|
-
|
|
572
|
-
def stamp(time) = time.utc.iso8601
|
|
573
|
-
end
|
|
574
|
-
end
|
|
575
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Samagotchi
|
|
4
|
-
module Web
|
|
5
|
-
# What a participant may learn of their review's usage, by their grant's
|
|
6
|
-
# exposure level (Grants::Grant#exposure; the review-composer plan's D3):
|
|
7
|
-
# the policy that maps a level to the usage fields the review route
|
|
8
|
-
# gives them (ParticipantReview#review_usage). One level today:
|
|
9
|
-
#
|
|
10
|
-
# - +own+ (every grant's): their own spend and cap, their own queued
|
|
11
|
-
# turns and queue limit, their link's expiry, the session's model.
|
|
12
|
-
# Never another participant's or the owner's usage, nor the session's.
|
|
13
|
-
#
|
|
14
|
-
# A later level (a tier that also shows the session's spend, say) adds
|
|
15
|
-
# its row to LEVELS and its fields' sources in ParticipantReview: one
|
|
16
|
-
# place each. A level this chi doesn't know (written by a newer chi, or
|
|
17
|
-
# a hand-edited store) reads as DEFAULT, the narrowest: an unknown level
|
|
18
|
-
# never shows more. See docs/internals/participants.md (Exposure).
|
|
19
|
-
module ParticipantExposure
|
|
20
|
-
OWN = "own"
|
|
21
|
-
DEFAULT = OWN
|
|
22
|
-
# level => the usage fields it shows, in the order the page reads them.
|
|
23
|
-
LEVELS = {
|
|
24
|
-
OWN => %i[spent cap queued queue_limit expires_at model].freeze
|
|
25
|
-
}.freeze
|
|
26
|
-
|
|
27
|
-
# +value+ as a level this chi knows, else DEFAULT.
|
|
28
|
-
def self.level(value) = LEVELS.key?(value) ? value : DEFAULT
|
|
29
|
-
|
|
30
|
-
# @return [Array<Symbol>] the fields +value+'s level shows
|
|
31
|
-
def self.fields(value) = LEVELS.fetch(level(value))
|
|
32
|
-
|
|
33
|
-
# The usage +value+'s level shows, each field read from +sources+
|
|
34
|
-
# (field => a callable): only the fields shown are computed, and a
|
|
35
|
-
# field a level names with no source raises (KeyError), never shows
|
|
36
|
-
# nothing silently.
|
|
37
|
-
# @param sources [Hash{Symbol => #call}]
|
|
38
|
-
# @return [Hash{Symbol => Object}]
|
|
39
|
-
def self.usage(value, sources)
|
|
40
|
-
fields(value).to_h { |field| [field, sources.fetch(field).call] }
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Samagotchi
|
|
4
|
-
module Web
|
|
5
|
-
# chi web's private state folders (web-grants/, web-push/): a folder
|
|
6
|
-
# only this user can enter (0700) with files only this user can read
|
|
7
|
-
# (0600). chi web refuses to start on one others can read (Server.launch);
|
|
8
|
-
# the reason words say what the folder holds.
|
|
9
|
-
module PrivateFiles
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
|
-
# Refuses +dir+ or any of +files+ when others can read it or it isn't
|
|
13
|
-
# this user's. Only these: the state dir above them is often 0755, and
|
|
14
|
-
# that is fine. A folder or file not made yet passes (it is made 0700 /
|
|
15
|
-
# 0600).
|
|
16
|
-
# @param holds [String] what is in them ("the participants' grants")
|
|
17
|
-
# @param error [Class] the exception to raise
|
|
18
|
-
def check!(dir, files, holds:, error:)
|
|
19
|
-
[dir, *files].each do |target|
|
|
20
|
-
stat = File.stat(target)
|
|
21
|
-
next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
|
|
22
|
-
|
|
23
|
-
raise error, "#{target} can be read by other users: chmod 700 #{dir} and 600 #{files.join(" ")} " \
|
|
24
|
-
"(it holds #{holds})"
|
|
25
|
-
rescue Errno::ENOENT
|
|
26
|
-
next
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|