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:
@@ -758,17 +758,12 @@ impl ApplyService {
|
||||
// a group-owned endpoint by `validate_bundle` via the inherited
|
||||
// targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
|
||||
// (kv/docs/files/pubsub) resolve live via the chain union at
|
||||
// dispatch; §4.5 M5 the stateful CRON + QUEUE kinds are allowed and
|
||||
// MATERIALIZED into a per-descendant-app row. Only `email` (a per-app
|
||||
// sealed inbound secret) is still rejected on a group.
|
||||
// dispatch; §4.5 M5 the stateful CRON + QUEUE + EMAIL kinds are
|
||||
// allowed and MATERIALIZED into a per-descendant-app row. An email
|
||||
// template resolves its inbound secret against the GROUP's own secret
|
||||
// store once at apply (shared-group-secret model); materialization
|
||||
// copies the sealed bytes verbatim.
|
||||
for t in &bundle.triggers {
|
||||
if matches!(t, BundleTrigger::Email { .. }) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"a group email trigger template is not yet supported \
|
||||
(per-app inbound secret); cron/queue/event kinds are allowed"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// §11.6: a `shared` trigger watches the group's SHARED collection.
|
||||
// Only the collection-scoped kinds can be shared (pubsub has no
|
||||
// shared topic store yet), and the watched collection must be one
|
||||
@@ -1048,13 +1043,16 @@ impl ApplyService {
|
||||
inbound_secret_ref, ..
|
||||
} = bt
|
||||
{
|
||||
// Email is app-only (a group template rejects it in
|
||||
// `validate_bundle_for`). Resolve the referenced secret,
|
||||
// Resolve the referenced secret against the OWNER's store,
|
||||
// decrypt it, and re-seal it into the email trigger's detail
|
||||
// row — the value never travels in the manifest.
|
||||
let app_id = owner.app_id().expect("email triggers are app-only");
|
||||
let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?;
|
||||
insert_email_trigger_tx(&mut *tx, app_id, sid, actor, &ct, &nonce)
|
||||
// row — the value never travels in the manifest. §4.5 M5: an
|
||||
// app trigger resolves the app's secret; a group EMAIL TEMPLATE
|
||||
// resolves the group's own secret once here, and materialization
|
||||
// copies the sealed bytes verbatim to each descendant.
|
||||
let (ct, nonce) = self
|
||||
.resolve_and_seal(owner.secret_owner(), inbound_secret_ref)
|
||||
.await?;
|
||||
insert_email_trigger_tx(&mut *tx, owner.as_script_owner(), sid, actor, &ct, &nonce)
|
||||
.await
|
||||
.map_err(map_trig)?;
|
||||
} else {
|
||||
@@ -1873,15 +1871,18 @@ impl ApplyService {
|
||||
|
||||
/// Resolve a referenced secret to plaintext, then re-seal it for an
|
||||
/// email trigger's inbound-secret column. The value never appears in
|
||||
/// the manifest — only the secret's name does.
|
||||
/// the manifest — only the secret's name does. §4.5 M5: `owner` is the
|
||||
/// app for an app-owned trigger, or the GROUP for a group email template
|
||||
/// (which resolves against the group's own secret store once at apply;
|
||||
/// materialization then copies the sealed bytes to each descendant).
|
||||
async fn resolve_and_seal(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: crate::secrets_service::SecretOwner,
|
||||
name: &str,
|
||||
) -> Result<(Vec<u8>, Vec<u8>), ApplyError> {
|
||||
let stored = self
|
||||
.secrets
|
||||
.get(crate::secrets_service::SecretOwner::App(app_id), "*", name)
|
||||
.get(owner, "*", name)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
@@ -1890,13 +1891,8 @@ impl ApplyService {
|
||||
(push it with `pic secret set {name}`)"
|
||||
))
|
||||
})?;
|
||||
let plaintext = crate::secrets_service::open(
|
||||
&self.master_key,
|
||||
crate::secrets_service::SecretOwner::App(app_id),
|
||||
name,
|
||||
&stored,
|
||||
)
|
||||
.map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
|
||||
let plaintext = crate::secrets_service::open(&self.master_key, owner, name, &stored)
|
||||
.map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
|
||||
// The inbound HMAC must be a non-empty string — an empty/whitespace
|
||||
// key is forgeable (preserves the spirit of audit 2026-06-11 H-B2).
|
||||
match plaintext.as_str() {
|
||||
@@ -2897,13 +2893,12 @@ impl ApplyOwner {
|
||||
}
|
||||
}
|
||||
|
||||
/// The app id, when this is an app node. Routes/triggers/email-secrets only
|
||||
/// exist on app nodes, so their reconcile paths call this with an
|
||||
/// `expect` — guarded by validation that rejects them on a group bundle.
|
||||
fn app_id(self) -> Option<AppId> {
|
||||
/// As a [`SecretOwner`] — for resolving an email trigger's inbound secret
|
||||
/// against the owner's store (§4.5 M5: an app trigger vs a group template).
|
||||
fn secret_owner(self) -> crate::secrets_service::SecretOwner {
|
||||
match self {
|
||||
Self::App(a) => Some(a),
|
||||
Self::Group(_) => None,
|
||||
Self::App(a) => crate::secrets_service::SecretOwner::App(a),
|
||||
Self::Group(g) => crate::secrets_service::SecretOwner::Group(g),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user