Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ RESEND_WEBHOOK_RECIPIENT_HASH_SECRET=
MAP_NODE_EMAIL_FROM=Greenpill Network <map@mail.greenpill.network>
MAP_NODE_EMAIL_REPLY_TO=Greenpill Network <map@mail.greenpill.network>
MAP_NODE_EDIT_BASE_URL=https://greenpill.network/map/edit
# Comma-separated moderator recipients. Keep real addresses in Fly secrets.
MAP_NODE_MODERATION_RECIPIENTS=
MAP_NODE_MODERATION_DIRECTUS_URL=https://admin.greenpill.network

# Public Garden newsletter subscribe form. Contacts are created globally in
# Resend; segment/topic ids are optional and stay on the agent service.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ shared contracts instead of one-off route or website fetch logic.

Public map-node submissions require an owner email. The agent stores that email only in `intake.map_node_private_contacts` so future owner updates can use one-use email magic links. Configure email sending on the agent with `RESEND_API_KEY`, `MAP_NODE_EMAIL_FROM`, `MAP_NODE_EMAIL_REPLY_TO`, and `MAP_NODE_EDIT_BASE_URL`; do not expose those values to the static website, Keystatic, generated JSON, or browser bundles. Map magic-link replies should route to the monitored map mailbox on the verified sending subdomain, currently `Greenpill Network <map@mail.greenpill.network>`. Missing or failing email provider configuration still returns the same neutral public edit-link response.

When Live Onboarding Mode is off, every accepted map node is pending. Configure the private `MAP_NODE_MODERATION_RECIPIENTS` secret on `network-agent` (comma-separated operational addresses) to receive an immediate Resend alert and a 9:00 AM Pacific daily reminder while pending nodes remain. Each alert links to the exact authenticated Directus record at `MAP_NODE_MODERATION_DIRECTUS_URL`; approve or reject there so Directus records the actor and change history. Alerts contain only review-safe map fields, never the owner email, raw note, IP address, user agent, or spam metadata. The public form also enforces a server-side daily IP limit and a non-visible bot trap before either a pending record or an alert is created.

For steward onboarding sessions, an approved node is projected as a steward only
when its private owner email matches an **active** Directus user with one chapter
editor assignment. The agent resolves this privately on every map read and
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"db:migrate": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/agent-db.migrate.ts",
"db:cleanup:map-node-edit-flow": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-edit-flow-cleanup.ts",
"db:deliver:map-node-edit-links": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-edit-link-delivery.ts",
"db:deliver:map-node-moderation": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/map-node-moderation-delivery.ts",
"impact:sync": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/agent-impact-sync.ts",
"content:snapshot": "bun run build:packages && bun --no-env-file scripts/operational-content.ts --write-snapshot",
"content:migrate": "bun run build:packages && bun --no-env-file --env-file-if-exists=.env.local scripts/operational-content.ts --migrate",
Expand All @@ -38,6 +39,7 @@
"directus:studio:setup": "bun --env-file-if-exists=.env.local scripts/directus-studio-setup.ts",
"directus:local:bootstrap": "bun --no-env-file scripts/wait-for-http.ts http://localhost:3302/server/ping 60000 && bun --no-env-file scripts/directus-operational-content-setup.ts && bun --no-env-file scripts/directus-studio-setup.ts",
"directus:steward:smoke": "bun --env-file-if-exists=.env.local scripts/directus-steward-smoke.ts",
"directus:map-moderation:smoke": "bun --no-env-file scripts/directus-map-moderation-smoke.ts",
"directus:users:invite": "bun --env-file-if-exists=.env.local scripts/directus-users.ts invite",
"plans:scaffold": "bun --no-env-file scripts/plan-hub.ts scaffold",
"plans:validate": "bun --no-env-file scripts/plan-hub.ts validate",
Expand All @@ -46,7 +48,7 @@
"agentic:check": "bun run agentic:guidance && bun run check:browser-verification-policy && bun run plans:validate && bun run ui:check",
"agentic:browser-proof": "bun scripts/require-authenticated-browser-qa.mjs && bun run ui:verify",
"agentic:verify": "bun scripts/require-authenticated-browser-qa.mjs && bun run ui:verify",
"test:agent": "bun run build:packages && bun test scripts/agent-contract.test.ts",
"test:agent": "bun run build:packages && bun test scripts/agent-contract.test.ts && bun test scripts/map-node-moderation.test.ts",
"test:chapter-impact": "bun run build:packages && bun test scripts/chapter-impact-contract.test.ts",
"test:content": "bun run build:packages && bun test scripts/public-content-contract.test.ts",
"test:map-edit": "bun run build:website && bun test scripts/map-edit-route.test.ts",
Expand Down
19 changes: 19 additions & 0 deletions packages/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,25 @@ the approved public projection available through `/map/state`. Turn it off
manually after the session. Do not expose this toggle through public URLs,
query parameters, browser storage, or generated website content.

In moderated mode, the agent sends configured operators a Resend alert with a
link to the exact `map_node_submissions` record in Directus. Sign in, review the
safe fields, and set `status` to `approved`, `rejected`, or `archived`; Directus
records the actor and status transition, while the database sets `approved_at`
on first approval. The `Pending map node approvals` bookmark is the fallback
queue when an email link has expired or been missed.

The agent retries temporary alert failures and Resend deduplicates a retry of
the same alert. If retries are exhausted, the `Failed map moderation alerts`
bookmark exposes only the delivery status and provider error for moderators and
trusted publishers; it never exposes recipient addresses, owner email, raw
notes, IP addresses, or spam metadata.

For a local end-to-end permission check, run
`bun run directus:map-moderation:smoke` while the local stack is running. It
creates and cleans up a temporary Steward Moderator, confirms that user can
approve a pending node, verifies `approved_at`, and checks Directus activity
records the moderator as the actor.

An approved node renders as a steward only when its private owner email matches
an **active** Directus user with one chapter-editor assignment. The agent reads
that relationship privately on every map projection and exposes only the public
Expand Down
1 change: 1 addition & 0 deletions packages/agent/fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ primary_region = 'iad'
[env]
NODE_ENV = 'production'
PORT = '8080'
MAP_NODE_MODERATION_DIRECTUS_URL = 'https://admin.greenpill.network'

[http_service]
internal_port = 8080
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
-- Durable delivery queue for moderator alerts. Public map-node intake writes
-- directly through the agent, so Directus cannot be the notification source.

create extension if not exists pgcrypto;

create table if not exists intake.map_node_moderation_notifications (
id uuid primary key default gen_random_uuid(),
kind text not null,
submission_id uuid references intake.map_node_submissions(id) on delete cascade,
digest_date date,
status text not null default 'queued',
attempts integer not null default 0,
next_attempt_at timestamptz not null default now(),
delivery_claimed_at timestamptz,
provider_message_id text,
provider_error text,
sent_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint map_node_moderation_notification_kind_check
check (kind in ('submission', 'daily_digest')),
constraint map_node_moderation_notification_status_check
check (status in ('queued', 'delivery_claimed', 'retry_scheduled', 'sent', 'failed', 'skipped')),
constraint map_node_moderation_notification_attempts_check
check (attempts >= 0),
constraint map_node_moderation_notification_shape_check
check (
(kind = 'submission' and submission_id is not null and digest_date is null)
or
(kind = 'daily_digest' and submission_id is null and digest_date is not null)
)
);

create unique index if not exists map_node_moderation_notification_submission_idx
on intake.map_node_moderation_notifications (submission_id)
where kind = 'submission';

create unique index if not exists map_node_moderation_notification_digest_idx
on intake.map_node_moderation_notifications (digest_date)
where kind = 'daily_digest';

create index if not exists map_node_moderation_notification_delivery_queue_idx
on intake.map_node_moderation_notifications (status, next_attempt_at, created_at);

create or replace function intake.touch_map_node_moderation_notification_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;

drop trigger if exists map_node_moderation_notification_touch_updated_at
on intake.map_node_moderation_notifications;

create trigger map_node_moderation_notification_touch_updated_at
before update on intake.map_node_moderation_notifications
for each row
execute function intake.touch_map_node_moderation_notification_updated_at();

create or replace function intake.apply_map_node_submission_moderation_transition()
returns trigger
language plpgsql
as $$
begin
if new.status = 'approved'::intake.map_node_status
and old.status is distinct from 'approved'::intake.map_node_status
and new.approved_at is null then
new.approved_at = now();
end if;

new.updated_at = now();
return new;
end;
$$;

drop trigger if exists map_node_submission_moderation_transition
on intake.map_node_submissions;

create trigger map_node_submission_moderation_transition
before update on intake.map_node_submissions
for each row
execute function intake.apply_map_node_submission_moderation_transition();
Loading
Loading