镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
af33072
feat(push): configure gateway origin and relay endpoint
brow Sep 2, 2026
7d7cc99
feat(push): require configured mobile and chart origins
brow Sep 2, 2026
6616b83
ci(helm): provide explicit gateway origin
brow Sep 2, 2026
4a60901
fix(push): clean up legacy enrollment state
brow Sep 2, 2026
bbc6cf3
Revert "fix(push): clean up legacy enrollment state"
brow Sep 2, 2026
f2c4ce7
docs(nip-pl): keep canonical gateway profile
brow Sep 2, 2026
aede861
fix(push): bind enrollment to relay gateway
brow Sep 2, 2026
236fd35
fix(push): preserve registered transcript audiences
brow Sep 2, 2026
38532e1
fix(push): keep gateway config off the NIP wire
brow Sep 2, 2026
a61a899
fix(mobile): reject malformed push gateway origins
brow Sep 2, 2026
86a90b6
test(mobile): cover push gateway origin validation
brow Sep 2, 2026
c5a3764
fix(push): revoke retired gateway installations
brow Sep 2, 2026
1dda5fd
fix(mobile): configure direct Xcode gateway builds
brow Sep 2, 2026
de4a26a
fix(push): revoke before replacing attest key
brow Sep 2, 2026
4922af6
docs(mobile): configure Xcode gateway build setting
brow Sep 2, 2026
711b50a
fix(push): bind cleanup to installation keys
brow Sep 2, 2026
6e8a067
fix(push): restore rollback gateway state
brow Sep 2, 2026
5925166
fix(push): clean retired gateways during registration
brow Sep 2, 2026
134b104
fix(push): retain endpoint for cleanup replay
brow Sep 2, 2026
5dc1278
fix(push): journal fresh enrollment endpoint
brow Sep 2, 2026
e99d3d5
fix(push): initialize gateway cleanup at startup
brow Sep 3, 2026
763517c
fix(push): retry startup gateway cleanup
brow Sep 3, 2026
76e31cd
fix(push): bound retired gateway retries
brow Sep 3, 2026
ce8d593
fix(push): continue retired gateway cleanup
brow Sep 3, 2026
895d5fe
fix(mobile): require HTTPS for release gateway
brow Sep 3, 2026
da10304
fix(push): reconcile restored enrollment journal
brow Sep 3, 2026
88561a9
fix(push): preserve shared installation during cleanup
brow Sep 3, 2026
2c60418
fix(mobile): reject release gateway ports
brow Sep 3, 2026
59ce830
Checkpoint retired installation cleanup
brow Sep 3, 2026
6a5c5ce
Retry ambiguous push revocation failures
brow Sep 3, 2026
c559fc6
Quarantine push grants before revocation
brow Sep 3, 2026
d283152
Reconcile committed push delegation generation
brow Sep 3, 2026
0074858
Scope push delegation generation fallback
brow Sep 3, 2026
0dff62c
Make push delegation cleanup recoverable
brow Sep 3, 2026
115f1dd
Merge remote-tracking branch 'origin/main' into configurable-push-gat…
brow Sep 3, 2026
adf2bf0
Journal shared push installation grants
brow Sep 3, 2026
39b6687
Retire grants for revoked push delegation
brow Sep 3, 2026
facee24
Checkpoint completed delegation cleanup
brow Sep 3, 2026
bb75c43
Reject ported push delivery URLs
brow Sep 3, 2026
bf39df6
Recover legacy push authority state
brow Sep 3, 2026
048e88c
Preserve configured legacy push origin
brow Sep 3, 2026
b0d0435
Wait for APNs before legacy push cleanup
brow Sep 3, 2026
9e08172
Migrate push leases before gateway cleanup
brow Sep 3, 2026
338c706
Checkpoint push migration retries
brow Sep 3, 2026
ae0bf99
Merge remote-tracking branch 'origin/main' into configurable-push-gat…
brow Sep 3, 2026
2f8ad3f
Scope push migration publication fence
brow Sep 3, 2026
239623e
Preserve push replacements across endpoint rotation
brow Sep 3, 2026
76b2e32
Distinguish same-gateway push replacements
brow Sep 3, 2026
7b874a6
Fence push cleanup against newer migration work
brow Sep 3, 2026
29779e9
Serialize push cleanup with enrollment
brow Sep 3, 2026
923544e
Disambiguate push installation conflicts
brow Sep 3, 2026
532c37d
Checkpoint completed push replacement origins
brow Sep 3, 2026
c36ba66
Fence push replacement checkpoints
brow Sep 3, 2026
44ce7fb
Confirm push lease acceptance before cleanup
brow Sep 3, 2026
c645d9c
Retain push revocation tombstones through expiry
brow Sep 3, 2026
bb75bec
Document Android push gateway input
brow Sep 3, 2026
2341ccb
Checkpoint shared push authorities atomically
brow Sep 3, 2026
22b73b2
Preserve shared push recovery authority
brow Sep 3, 2026
3373281
Update embedded migration inventory
brow Sep 3, 2026
ad0e67f
Continue independent push migration groups
brow Sep 3, 2026
2fcf564
Recover quarantined push installations
brow Sep 3, 2026
49d8ac3
Recover response-lost legacy enrollment
brow Sep 3, 2026
843170d
Migrate gateway tombstone indexes
brow Sep 3, 2026
838d0dc
Fence push migration acceptance
brow Sep 3, 2026
4b59a1d
Fence push cleanup against token rotation
brow Sep 3, 2026
deab86b
Preserve push migration work after rotation
brow Sep 3, 2026
18d4a07
Journal push migration authority groups
brow Sep 3, 2026
ea811c2
Fence stale push lease publication
brow Sep 3, 2026
14d38ce
Fence stale push enrollment
brow Sep 3, 2026
8c3b63c
test(push): apply real gateway migrations in PostgreSQL fixtures
brow Sep 8, 2026
fe1fe60
refactor(push): remove unshipped legacy gateway migration
brow Sep 8, 2026
581fd52
Merge main to satisfy the pre-push branch-skew gate
brow Sep 8, 2026
c9f6795
fix(push): retain registration on first notification opt-in
brow Sep 8, 2026
883b8e5
fix(mobile): make push gateway configuration optional
brow Sep 8, 2026
a9d6a32
fix(mobile): support optional gateway arguments on Bash 3.2
brow Sep 14, 2026
bb279b7
fix(deps): update rustls for RUSTSEC-2026-0285
brow Sep 14, 2026
b0a2446
Merge remote-tracking branch 'origin/main' into configurable-push-gat…
brow Sep 14, 2026
6b63fd7
test(mobile): order voice-note fixture cleanup before disposal
brow Sep 14, 2026
9c21725
fix(push): refuse populated pre-launch gateway databases
brow Sep 14, 2026
ae5f660
Merge remote-tracking branch 'origin/main' into configurable-push-gat…
brow Sep 14, 2026
ba3856a
test(push): reuse gateway migrator for bootstrap fixtures
brow Sep 14, 2026
6947cb3
test(push): expose bootstrap regression to PostgreSQL discovery
brow Sep 15, 2026
5f546cf
Merge remote-tracking branch 'origin/main' into configurable-push-gat…
brow Sep 15, 2026
74da60b
chore: retrigger automated PR reviews
brow Sep 15, 2026
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
5 changes: 2 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,9 @@ RELAY_URL=ws://localhost:3000
# BUZZ_WEB_DIR=./web/dist

# NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone
# never enables it. When enabled and the URL is absent, the canonical
# https://push.buzz.xyz/v1/deliveries/apns endpoint is used.
# never enables it. When enabled, the exact HTTPS delivery URL is required.
BUZZ_PUSH_ENABLED=false
# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns
BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns

# -----------------------------------------------------------------------------
# Admin Dashboard (private moderation surface)
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/_ci-clients.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ jobs:
- name: Analyze
run: cd mobile && flutter analyze
- name: Test
run: cd mobile && flutter test
run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
- name: Test mobile without push configuration
run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart
- name: Test mobile recipe argument forwarding
run: /bin/bash scripts/test-mobile-gateway-recipes.sh
- name: Build Android debug APK
run: just mobile-build-android

Expand All @@ -134,7 +138,9 @@ jobs:
- name: Build complete iOS simulator app
run: cd mobile && flutter build ios --simulator --debug --no-pub
- name: Build complete unsigned iOS release
run: cd mobile && flutter build ios --release --no-codesign --no-pub
run: |
/bin/bash scripts/test-mobile-gateway-recipes.sh
cd mobile && flutter build ios --release --no-codesign --no-pub

results:
name: Results
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
cd mobile
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
```

Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).
Expand Down
25 changes: 22 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,9 @@ mobile-check:

# Run mobile tests
mobile-test:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test
/bin/bash ./scripts/test-mobile-gateway-recipes.sh
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test test/shared/push/push_unconfigured_build_test.dart

# Regenerate the emoji dataset asset from desktop's emoji-mart install.
# Output is committed — rerun after bumping @emoji-mart/data.
Expand All @@ -838,8 +840,16 @@ mobile-emoji-data:

# Compile an unsigned Android debug APK (worktree-aware debug identity)
mobile-build-android:
#!/usr/bin/env bash
set -euo pipefail
./scripts/mobile-worktree-overrides.sh
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub
set -- build apk --debug --no-pub
if [[ -n "${BUZZ_PUSH_GATEWAY_URL:-}" ]]; then
set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${BUZZ_PUSH_GATEWAY_URL}"
fi
unset GIT_DIR GIT_WORK_TREE
cd {{mobile_dir}}
flutter "$@"

# Run the mobile app on iOS simulator (worktree-aware debug identity)
mobile-dev:
Expand All @@ -850,9 +860,18 @@ mobile-dev:
sleep 3
fi
./scripts/mobile-worktree-overrides.sh
gateway_url="${BUZZ_PUSH_GATEWAY_URL:-}"
overrides_file="{{mobile_dir}}/ios/Flutter/AppOverrides.xcconfig"
if [[ -z "$gateway_url" && -f "$overrides_file" ]]; then
gateway_url="$(sed -nE 's/^[[:space:]]*BUZZ_PUSH_GATEWAY_URL[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$/\1/p' "$overrides_file" | tail -n 1 | sed 's/\$()//g')"
fi
set -- run
if [[ -n "$gateway_url" ]]; then
set -- "$@" --dart-define="BUZZ_PUSH_GATEWAY_URL=${gateway_url}"
fi
cd {{mobile_dir}}
unset GIT_DIR GIT_WORK_TREE
flutter run
flutter "$@"

# Uninstall stale worktree-suffixed Buzz debug installs (production apps kept)
mobile-clean:
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 44);
assert_eq!(migrations.len(), 45);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Revoked installations remain as retry tombstones until authority expiry.
-- Restrict ownership uniqueness to live rows so a replacement can enroll
-- without deleting the old installation's idempotency state.
ALTER TABLE push_gateway_installations
DROP CONSTRAINT push_gateway_installations_app_attest_key_id_key;
ALTER TABLE push_gateway_installations
DROP CONSTRAINT push_gateway_installations_app_profile_token_fingerprint_key;

CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key
ON push_gateway_installations (app_attest_key_id)
WHERE revoked_at IS NULL;

CREATE UNIQUE INDEX push_gateway_installations_active_profile_token
ON push_gateway_installations (app_profile, token_fingerprint)
WHERE revoked_at IS NULL;
111 changes: 103 additions & 8 deletions crates/buzz-push-gateway/src/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ pub enum DeliveryDisposition {
pub enum AuthorityError {
#[error("authority state rejected the request")]
Rejected,
#[error("installation authority is already live")]
Conflict,
#[error("authority request rate exceeded")]
RateLimited,
#[error("authority store unavailable")]
Expand Down Expand Up @@ -140,6 +142,13 @@ pub trait AuthorityStore: Send + Sync {
now: i64,
) -> Result<Option<Installation>, AuthorityError>;
async fn installation(&self, id: Uuid, now: i64) -> Result<Installation, AuthorityError>;
/// Load an unexpired installation for authenticated revocation retry,
/// including its terminal tombstone.
async fn installation_for_revocation(
&self,
id: Uuid,
now: i64,
) -> Result<Installation, AuthorityError>;
async fn advance_assertion_counter(
&self,
installation_id: Uuid,
Expand All @@ -155,8 +164,9 @@ pub trait AuthorityStore: Send + Sync {
token_ciphertext: Vec<u8>,
token_fingerprint: [u8; 32],
) -> Result<(), AuthorityError>;
/// Revoke an active delegation only when `expected_generation` is current,
/// retaining that generation as the replacement watermark.
/// Revoke a delegation only when `expected_generation` is current,
/// retaining that generation as the replacement watermark. Repeating the
/// exact revocation succeeds so clients can recover from a lost response.
async fn revoke_delegation(
&self,
installation_id: Uuid,
Expand Down Expand Up @@ -267,7 +277,7 @@ impl AuthorityStore for MemoryAuthorityStore {
if s.installations.contains_key(&n.id) {
return Err(AuthorityError::Rejected);
}
let replaced = s
let matching = s
.installations
.values()
.filter(|installation| {
Expand All @@ -277,14 +287,22 @@ impl AuthorityStore for MemoryAuthorityStore {
})
.map(|installation| installation.id)
.collect::<Vec<_>>();
if replaced.iter().any(|id| {
if matching.iter().any(|id| {
s.installations
.get(id)
.is_some_and(|installation| !installation.revoked && installation.expires_at >= now)
}) {
// App identity and token possession never supersede a live installation.
return Err(AuthorityError::Rejected);
return Err(AuthorityError::Conflict);
}
let replaced = matching
.into_iter()
.filter(|id| {
s.installations
.get(id)
.is_some_and(|installation| installation.expires_at < now)
})
.collect::<Vec<_>>();
for id in replaced {
if let Some(old) = s.installations.remove(&id) {
s.token_owners.remove(&(old.profile, old.token_fingerprint));
Expand Down Expand Up @@ -323,6 +341,20 @@ impl AuthorityStore for MemoryAuthorityStore {
Ok(i.clone())
}

async fn installation_for_revocation(
&self,
id: Uuid,
now: i64,
) -> Result<Installation, AuthorityError> {
let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?;
let i = s
.installations
.get(&id)
.filter(|i| i.expires_at >= now)
.ok_or(AuthorityError::Rejected)?;
Ok(i.clone())
}

async fn matching_installation(
&self,
key_id: &[u8],
Expand Down Expand Up @@ -361,7 +393,7 @@ impl AuthorityStore for MemoryAuthorityStore {
.installations
.get_mut(&id)
.ok_or(AuthorityError::Rejected)?;
if i.revoked || i.assertion_counter != previous {
if i.assertion_counter != previous {
return Err(AuthorityError::Rejected);
}
i.assertion_counter = next;
Expand Down Expand Up @@ -449,9 +481,12 @@ impl AuthorityStore for MemoryAuthorityStore {
.delegations
.get_mut(&key)
.ok_or(AuthorityError::Rejected)?;
if old.revoked || expected_generation != old.generation {
if expected_generation != old.generation {
return Err(AuthorityError::Rejected);
}
if old.revoked {
return Ok(());
}
old.revoked = true;
Ok(())
}
Expand All @@ -470,6 +505,9 @@ impl AuthorityStore for MemoryAuthorityStore {
.installations
.get_mut(&id)
.ok_or(AuthorityError::Rejected)?;
if i.revoked && i.endpoint_epoch == new {
return Ok(());
}
if i.revoked || i.endpoint_epoch != expected {
return Err(AuthorityError::Rejected);
}
Expand Down Expand Up @@ -758,7 +796,7 @@ mod tests {
store
.create_installation(replacement(Uuid::from_u128(5)), 1_999)
.await,
Err(AuthorityError::Rejected)
Err(AuthorityError::Conflict)
);
store
.create_installation(replacement(Uuid::from_u128(5)), 2_001)
Expand Down Expand Up @@ -838,6 +876,10 @@ mod tests {
.revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1)
.await
.expect("the current generation can be revoked");
store
.revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1)
.await
.expect("the exact revocation is idempotent after response loss");
assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4())
.await
.is_err());
Expand Down Expand Up @@ -878,4 +920,57 @@ mod tests {
.await
.expect("generation 2 authority is active");
}

#[tokio::test]
async fn installation_revocation_is_authenticated_and_idempotent() {
let store = store().await;
let id = Uuid::from_u128(1);
let before = store.installation(id, 1_000).await.unwrap();

store
.revoke_installation(id, 1, 2)
.await
.expect("the current endpoint epoch can be revoked");
let tombstone = store
.installation_for_revocation(id, 1_000)
.await
.expect("revocation retry can authenticate against the tombstone");
assert!(tombstone.revoked);
store
.advance_assertion_counter(id, before.assertion_counter, before.assertion_counter + 1)
.await
.expect("an authenticated tombstone retry can advance its assertion counter");
store
.revoke_installation(id, 1, 2)
.await
.expect("the exact installation revocation is idempotent");
store
.create_installation(
NewInstallation {
id: Uuid::from_u128(5),
app_attest_key_id: vec![1],
app_attest_public_key: vec![5; 33],
assertion_counter: 0,
profile: AppProfile::BuzzIosDogfood,
token_ciphertext: vec![6],
token_fingerprint: [4; 32],
endpoint_epoch: 1,
expires_at: 3_000,
},
1_001,
)
.await
.expect("a replacement can reuse ownership without deleting the tombstone");
assert!(
store
.installation_for_revocation(id, 1_001)
.await
.expect("replacement preserves the prior revocation tombstone")
.revoked
);
assert_eq!(
store.revoke_installation(id, 2, 3).await,
Err(AuthorityError::Rejected)
);
}
}
Loading
Loading