feat(stateful-templates): materialize group email templates (M5.5)
Close the last M5 gap: a [group] may now declare an email trigger template, materialized into a per-descendant-app row like cron/queue. Uses the shared-group-secret model: the template resolves its inbound_secret_ref against the GROUP's own secret store once at apply and seals it (resolve_and_seal + insert_email_trigger_tx generalized to a SecretOwner / ScriptOwner). Email secrets are v0/no-AAD, so the sealed blob is portable across rows — materialize copies the bytes verbatim into each descendant (no master key, no reseal, no new schema column, no threading into the CRUD hooks). Each copy has its own trigger_id / inbound webhook URL. - apply_service/manifest: drop the two group-email rejections; a group email template with an unset group secret still fails apply hard. - materialize: add "email" to MATERIALIZED_KINDS + a byte-copy detail arm. - trigger_repo: email_inbound_target gains AND t.app_id IS NOT NULL so a group TEMPLATE row is never directly invocable via its own webhook URL (mirrors the cron/queue app_id guards). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,16 +6,19 @@
|
||||
//! into an app-owned copy — `materialized_from = template.id` — for every
|
||||
//! descendant app, and removes copies whose (app, template) pair no longer
|
||||
//! inherits. The copy owns its per-app state (cron `last_fired_at`, the queue
|
||||
//! advisory lock, the email sealed secret), so the unchanged dispatch paths work
|
||||
//! against it as if it were hand-authored.
|
||||
//! advisory lock), so the unchanged dispatch paths work against it as if it were
|
||||
//! hand-authored. An EMAIL copy is a verbatim byte-copy of the group template's
|
||||
//! sealed inbound secret — the group resolved + sealed it against its own store
|
||||
//! once at apply (shared-group-secret model), and email secrets are v0/no-AAD so
|
||||
//! the ciphertext is portable across rows; no master key is needed here.
|
||||
//!
|
||||
//! Full-live like `rebuild_route_table`: called at the same chokepoints (apply,
|
||||
//! app create/delete, group reparent). A precise create/delete diff (not
|
||||
//! delete-all-then-recreate) preserves `last_fired_at` on unchanged cron copies.
|
||||
//!
|
||||
//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken,
|
||||
//! or an email whose descendant lacks the referenced secret) — the caller
|
||||
//! surfaces them; materialization of the other pairs still proceeds.
|
||||
//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken)
|
||||
//! — the caller surfaces them; materialization of the other pairs still
|
||||
//! proceeds.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -23,7 +26,7 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The stateful kinds that materialize (M5.2 cron; M5.4 queue; M5.5 email).
|
||||
const MATERIALIZED_KINDS: &[&str] = &["cron", "queue"];
|
||||
const MATERIALIZED_KINDS: &[&str] = &["cron", "queue", "email"];
|
||||
|
||||
/// Fixed advisory-lock key serializing all materialization reconcilers (§4.5
|
||||
/// M5). Two reconcilers running concurrently (e.g. two parallel app-creates)
|
||||
@@ -207,6 +210,24 @@ async fn materialize_one(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
"email" => {
|
||||
// §M5.5: the group template sealed its inbound HMAC secret against
|
||||
// the GROUP's own store once at apply (shared-group-secret model).
|
||||
// The secret is v0 (no AAD) — bound only to the master key, not the
|
||||
// owning row — so a verbatim byte-copy is a valid per-app secret; no
|
||||
// master key is needed here. Each copy has its own trigger_id (its
|
||||
// own inbound webhook URL).
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
||||
SELECT $1, inbound_secret_encrypted, inbound_secret_nonce \
|
||||
FROM email_trigger_details WHERE trigger_id = $2",
|
||||
)
|
||||
.bind(new_id.0)
|
||||
.bind(template_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
|
||||
Reference in New Issue
Block a user