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:
MechaCat02
2026-07-02 20:24:10 +02:00
parent 05373814b2
commit 9dc9191cb2
4 changed files with 80 additions and 57 deletions

View File

@@ -792,25 +792,35 @@ pub(crate) async fn insert_trigger_tx(
}
/// Insert an email trigger within a transaction. The inbound HMAC secret
/// is sealed by the apply engine (resolved from the app's secret store);
/// is sealed by the apply engine (resolved from the owner's secret store);
/// this writes the ciphertext. Parent retry settings match the
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
///
/// §4.5 M5: `owner` is app-XOR-group. A `ScriptOwner::Group` writes an email
/// trigger TEMPLATE (group_id set, app_id NULL) that is never dispatched
/// directly (`email_inbound_target` filters `app_id IS NOT NULL`) — the
/// materializer copies it into a per-descendant-app row.
pub(crate) async fn insert_email_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
owner: ScriptOwner,
script_id: ScriptId,
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
) -> Result<TriggerId, TriggerRepoError> {
let (owner_app_id, owner_group_id) = match owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
};
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
) VALUES ($1, $2, $3, 'email', TRUE, 'async', 3, 'exponential', 1000, $4) RETURNING id",
)
.bind(app_id.into_inner())
.bind(owner_app_id)
.bind(owner_group_id)
.bind(script_id.into_inner())
.bind(registered_by.into_inner())
.fetch_one(&mut **tx)
@@ -1315,12 +1325,16 @@ impl TriggerRepo for PostgresTriggerRepo {
trigger_id: TriggerId,
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
let row: Option<EmailInboundRow> = sqlx::query_as(
// §4.5 M5: `t.app_id IS NOT NULL` excludes a group email TEMPLATE
// row — only its materialized per-app copies are directly invocable
// (mirrors the cron-scheduler / queue-consumer guards). A template
// has no app to run under, so hitting its webhook URL must 404.
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
t.registered_by_principal, \
d.inbound_secret_encrypted, d.inbound_secret_nonce \
FROM triggers t \
JOIN email_trigger_details d ON d.trigger_id = t.id \
WHERE t.id = $1 AND t.kind = 'email'",
WHERE t.id = $1 AND t.kind = 'email' AND t.app_id IS NOT NULL",
)
.bind(trigger_id.into_inner())
.fetch_optional(&self.pool)