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
50 changes: 31 additions & 19 deletions docs/farcaster-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,33 +339,45 @@ behind a feature gate. Raw notification tokens stay inside one private
Cloudflare Durable Object per FID, never in React, browser storage, logs, URLs,
public state, or SpacetimeDB.

After Hermes has committed and verified founder admission, it invokes a
separate-secret operator endpoint. That endpoint resolves current admission
again; the Durable Object repeats the exact epoch check immediately before each
delivery attempt. Queue-before-webhook races are retained without a token for
at most 24 hours, signed opt-outs erase token material immediately, invalid
tokens are purged, retry attempts are bounded, and one epoch cannot notify
twice. `notify-admitted <fid> --confirm` is the idempotent recovery path if the
database commit succeeds but the notification side effect is interrupted.
Notification preference and delivery add no SpacetimeDB schema or browser
authority.

The current Worker payload is deliberately retained for this frontend stage:
Before Hermes requests administrator authority or mutates admission, it calls a
separate-secret operator endpoint for the exact pending access-request
timestamp. The Durable Object proves that request is still pending and that
admission is not enabled immediately before sending. For an opted-in player,
Hermes proceeds only after Farcaster reports the matching token in
`successfulTokens`; without notification consent, it records the explicit
`not-subscribed` result and may proceed. Provider acceptance proves handoff to
Farcaster, not device display or that the player opened the alert.

Queue-before-webhook races are retained without a token for at most 24 hours,
signed opt-outs erase token material immediately, invalid tokens are purged,
retry attempts are bounded, and one request generation cannot notify twice.
`notify-admitted <fid> --confirm` remains an exact-epoch reconciliation command
for legacy or exceptional already-committed admissions; it is not the normal
admission sequence. Notification preference and delivery add no SpacetimeDB
schema or browser authority.

The reviewed payloads are:

```txt
normal admission:
notificationId: warpkeep-access-approved-v2-r<pending-request-timestamp>
title: Admission approved
body: The Hegemony is finalizing your Realm access. Your keep will open shortly.

already-live reconciliation:
notificationId: warpkeep-access-approved-v1-e<positive-auth-epoch>
title (23): The Hegemony admits you
body (56): Your keep awaits in Genesis 001. Enter the living Realm.
title: The Hegemony admits you
body: Your keep awaits in Genesis 001. Enter the living Realm.
targetUrl: https://warpkeep.com/?miniApp=true
```

The title and body are within Farcaster's bounds, contain no identity or
private state, and accurately describe the event. Changing them would require
a separate reviewed Worker rollout, so copy changes are not coupled to this
default-off client integration.
The titles and bodies are within Farcaster's bounds, contain no identity or
private state, and accurately describe their generation. Copy changes require
a reviewed Worker rollout.

For a notification launch, the browser retains only
`location.type === "notification"` and a notification ID matching
`location.type === "notification"` and a notification ID matching either
`warpkeep-access-approved-v2-r<positiveInteger>` or the rollback-compatible
`warpkeep-access-approved-v1-e<positiveInteger>` within the 128-character
limit. Host title and body are discarded. Warpkeep then shows a short
confirmation state and runs normal Quick Auth, current admission, Terms, and
Expand Down
35 changes: 19 additions & 16 deletions docs/operations/alpha-activation.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,13 @@ a SpacetimeDB schema change. Roll them out in this order:
canary events pass. Then change only that public presentation gate to the
literal value `true` in a reviewed frontend release; it does not enable the
Worker or grant admission.
7. Give Hermes the operator secret through its private environment. A committed
admission may call the notification route best-effort; if delivery cannot be
queued, preserve the admission result and reconcile later with
`npm run stdb:notify-admitted -- <fid> --confirm`.
7. Give Hermes both isolated secrets through its private environment. For
`allow-fid` and confirmed `admit-founder`, Hermes must queue the exact pending
request generation before requesting an administrator token. If the player
opted in, require Farcaster provider acceptance before mutating admission;
`queued` or `delivery-exhausted` aborts unchanged. `not-subscribed` is an
explicit audited fallback for a player without consent. Keep
`notify-admitted` only for idempotent already-live reconciliation.

### Owner canary and end-to-end acceptance

Expand Down Expand Up @@ -293,23 +296,23 @@ it.
change.
5. Confirm one new signed subscription pair through the same fixed events, then
admit the account through the existing reviewed Hermes dry-run, mutation,
and postflight sequence. Admission remains authoritative even if the
notification side effect fails. If the automatic side effect is ambiguous,
run `npm run stdb:notify-admitted -- <fid> --confirm` once; accept only
`queued`, `already-sent`, `delivery-exhausted`, or `not-subscribed`.
6. Require one approval notification for the resulting positive auth epoch.
Its target must be exactly `https://warpkeep.com/?miniApp=true`. Tap it and
verify the calm confirmation state, fresh Quick Auth, current admission,
current Terms when required, and entry through the existing canonical keep.
No notification context may create a second keep or bypass Terms.
and postflight sequence. Require the operator receipt to show provider
acceptance for the exact pending-request generation before the SpacetimeDB
mutation is submitted. Provider acceptance proves Farcaster handoff, not
device presentation or that the player opened the alert.
6. Require one approval notification for that request generation. Its target
must be exactly `https://warpkeep.com/?miniApp=true`. Tap it and verify the
calm confirmation state, fresh Quick Auth, current admission, current Terms
when required, and entry through the existing canonical keep. No
notification context may create a second keep or bypass Terms.
7. Disable notifications or remove Warpkeep again, require the fixed
unsubscribe events, and confirm Realm access remains unchanged. Repeat the
complete acceptance on current Farcaster iOS and Android before declaring
the client rollout complete.

The current Worker copy is intentionally unchanged during this frontend stage:
`The Hegemony admits you` (23 characters) and
`Your keep awaits in Genesis 001. Enter the living Realm.` (56 characters).
The normal pending-request notification is `Admission approved` with
`The Hegemony is finalizing your Realm access. Your keep will open shortly.` The older
`The Hegemony admits you` payload remains only for already-live reconciliation.
Both are bounded and privacy-safe. Any copy change requires a separate reviewed
Worker rollout.

Expand Down
203 changes: 183 additions & 20 deletions scripts/hermes-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ const CONNECT_TIMEOUT_MS = 30_000;
const OPERATION_TIMEOUT_MS = 15_000;
const MAX_ADMIN_TOKEN_RESPONSE_BYTES = 32 * 1_024;
const ADMISSION_NOTIFICATION_PATH = 'v1/admin/admission-notification';
const ADMISSION_NOTIFICATION_STATUS_PATH = 'v1/admin/admission-notification-status';
const ADMISSION_NOTIFICATION_SETTLEMENT_WAIT_MILLISECONDS = 35_000;
const ADMIN_TOKEN_CLOCK_SAFETY_MILLISECONDS = 20_000;
const MAX_RESOURCE_BACKFILL_FOUNDERS = 100n;
const GENESIS_GENERATION_V2_WORLD_CELLS = 1_261n;
Expand Down Expand Up @@ -1353,6 +1355,78 @@ export function verifyFounderAdmissionResourcePostconditionV4(
return verified;
}

export function verifyFounderReenablePrecondition(
world: GenesisExpansionStatusV3,
resources: ResourceAggregateV4,
target: AccessRequestResetStatus,
): Readonly<{
world: GenesisExpansionStatusV3;
resources: ResourceAggregateV4;
target: AccessRequestResetStatus;
}> {
verifyFounderAdmissionCheckpointV3(world, false);
verifyExpectedResourceAggregateV4(resources, world.allowedFids);
if (
target.admissionState !== 'disabled'
|| target.requestState !== 'pending'
|| target.requestCycle !== BigInt(target.authEpoch) + 1n
|| target.requestedAtMicros === undefined
) {
fail('Existing founder re-enable requires one exact pending access request.');
}
return Object.freeze({
world: Object.freeze({ ...world }),
resources: Object.freeze({ ...resources }),
target: Object.freeze({ ...target }),
});
}

export function verifyFounderReenablePostcondition(
world: GenesisExpansionStatusV3,
resources: ResourceAggregateV4,
target: AccessRequestResetStatus,
before: ReturnType<typeof verifyFounderReenablePrecondition>,
): void {
verifyFounderAdmissionCheckpointV3(world, false);
if (
target.admissionState !== 'enabled'
|| target.authEpoch !== before.target.authEpoch + 1
|| target.requestState !== 'resolved'
|| target.requestCycle !== before.target.requestCycle
|| target.requestedAtMicros !== before.target.requestedAtMicros
) {
fail(
'Existing founder re-enable postcondition failed. The mutation outcome may be '
+ 'indeterminate; perform a fresh bounded read-only inspection before any retry.',
);
}
for (const field of Object.keys(before.world) as (keyof GenesisExpansionStatusV3)[]) {
const expected = field === 'enabledAllowedFids'
? (before.world[field] as bigint) + 1n
: field === 'auditEntries'
? (before.world[field] as bigint) + 1n
: before.world[field];
if (world[field] !== expected) {
fail(
'Existing founder re-enable changed an unexpected Realm aggregate. '
+ 'Do not retry before a bounded read-only investigation.',
);
}
}
const verifiedResources = verifyExpectedResourceAggregateV4(
resources,
before.world.allowedFids,
);
for (const field of Object.keys(before.resources) as (keyof ResourceAggregateV4)[]) {
if (verifiedResources[field] !== before.resources[field]) {
fail(
'Existing founder re-enable changed persistent resource state. '
+ 'Do not retry before a bounded read-only investigation.',
);
}
}
}

export function verifyGenesisExpansionPreconditionV3(
status: GenesisExpansionStatusV3,
): GenesisExpansionStatusV3 {
Expand Down Expand Up @@ -1630,30 +1704,81 @@ export async function requestAdmissionNotification(
return status;
}

async function notifyCommittedAdmission(
export async function inspectAdmissionNotification(
bridgeUrl: string,
fid: bigint,
secret: string | undefined,
): Promise<void> {
if (secret === undefined) {
console.warn(
'Admission committed; Farcaster notification was not queued because the local '
+ 'notification operator credential is unavailable. Run notify-admitted with --confirm.',
);
return;
}
secret: string,
fetchImpl: typeof fetch = fetch,
): Promise<AdmissionNotificationStatus> {
readNotificationOperatorSecret(secret);
let response: Response;
try {
const status = await requestAdmissionNotification(bridgeUrl, fid, secret);
console.log(JSON.stringify({ admissionNotification: status }));
response = await fetchImpl(new URL(ADMISSION_NOTIFICATION_STATUS_PATH, `${bridgeUrl}/`), {
method: 'POST',
headers: {
authorization: `Bearer ${secret}`,
accept: 'application/json',
'content-type': 'application/json',
'cache-control': 'no-store',
},
body: JSON.stringify({ fid: fid.toString() }),
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(10_000),
});
} catch {
// The database mutation is already authoritative. Never turn a delivery
// side-effect failure into an apparent admission failure that invites an
// unsafe reducer retry; the exact-epoch reconciliation command is idempotent.
console.warn(
'Admission committed; Farcaster notification was not queued. '
+ 'Run notify-admitted with --confirm after checking the bridge.',
fail('Could not reach the Warpkeep admission notification bridge.');
}
if (!response.ok) fail('The Warpkeep admission notification bridge rejected inspection.');
const body = await readBoundedAdminResponse(response);
const status = body && typeof body === 'object' && !Array.isArray(body)
? (body as { status?: unknown }).status
: undefined;
if (
status !== 'queued'
&& status !== 'already-sent'
&& status !== 'delivery-exhausted'
&& status !== 'not-subscribed'
) {
fail('The Warpkeep admission notification bridge returned invalid diagnostics.');
}
return status;
}

export async function requireNotificationBeforeAdmission(
bridgeUrl: string,
fid: bigint,
secretValue: string | undefined,
fetchImpl: typeof fetch = fetch,
sleep: AdminTokenSleeper = sleepForAdminTokenReadiness,
): Promise<AdmissionNotificationStatus> {
const secret = readNotificationOperatorSecret(secretValue);
let status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl);
if (status === 'queued') {
await sleep(ADMISSION_NOTIFICATION_SETTLEMENT_WAIT_MILLISECONDS);
// Requeue through the authority-checking endpoint instead of trusting a
// generic status snapshot. This binds the go/no-go decision to whichever
// exact pending request is still current after the wait.
status = await requestAdmissionNotification(bridgeUrl, fid, secret, fetchImpl);
}
if (status === 'queued') {
fail(
'Farcaster has not accepted the pending admission notification. '
+ 'Admission remains unchanged; retry after inspecting token-free bridge diagnostics.',
);
}
if (status === 'delivery-exhausted') {
fail(
'Farcaster notification delivery is exhausted. '
+ 'Admission remains unchanged; reconcile notification consent before retrying.',
);
}
console.log(JSON.stringify({
admissionNotification: status,
providerAcceptanceRequired: status !== 'not-subscribed',
providerAcceptedBeforeAdmission: status === 'already-sent',
}));
return status;
}

type AdminTokenSleeper = (milliseconds: number) => Promise<void>;
Expand Down Expand Up @@ -2428,6 +2553,20 @@ async function main() {
await readStatus(connection, 'v4') as ResourceAggregateV4,
before.allowedFids,
);
const targetAuthEpoch = await withOperationTimeout(
connection.procedures.adminGetFidAuthEpoch({ fid }),
);
if (targetAuthEpoch !== 0) {
fail('Profiled admission requires a founder FID that has not been admitted before.');
}
// All local, credential, connection, plan, profile, capacity, and
// persistent graph checks have passed. Bind provider acceptance to the
// still-current request immediately before the one admission mutation.
await requireNotificationBeforeAdmission(
bridgeUrl,
fid,
notificationOperatorSecret,
);
claimReviewedFounderAdmissionPlan({
plan: admissionPlan,
sha256: admissionPlanReference.sha256,
Expand All @@ -2451,11 +2590,35 @@ async function main() {
beforeResources,
);
founderAdmissionClaimed = false;
await notifyCommittedAdmission(bridgeUrl, fid, notificationOperatorSecret);
mutationStatusHandled = true;
} else if (command === 'allow-fid' && fid !== undefined && note !== undefined) {
const beforeTarget = projectAccessRequestResetStatus(
await withOperationTimeout(
connection.procedures.adminGetAccessRequestResetStatusV1({ fid }),
),
);
const before = verifyFounderReenablePrecondition(
await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3,
await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4,
beforeTarget,
);
await requireNotificationBeforeAdmission(
bridgeUrl,
fid,
notificationOperatorSecret,
);
await withOperationTimeout(connection.reducers.adminAllowFid({ fid, note }));
await notifyCommittedAdmission(bridgeUrl, fid, notificationOperatorSecret);
verifyFounderReenablePostcondition(
await readStatus(connection, 'v3', false, undefined, false) as GenesisExpansionStatusV3,
await readStatus(connection, 'v4', false, undefined, false) as ResourceAggregateV4,
projectAccessRequestResetStatus(
await withOperationTimeout(
connection.procedures.adminGetAccessRequestResetStatusV1({ fid }),
),
),
before,
);
mutationStatusHandled = true;
} else if (command === 'disable-fid' && fid !== undefined && note !== undefined) {
await withOperationTimeout(connection.reducers.adminDisableFid({ fid, note }));
} else if (command === 'bump-auth-epoch' && fid !== undefined && note !== undefined) {
Expand Down
Loading