Skip to content
Closed
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
23 changes: 18 additions & 5 deletions .github/workflows/content-pipeline-watchdog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ jobs:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -uo pipefail

# Every API call is `|| true`: the step shell is `bash -e`, so an
# unguarded 403 aborts mid-script and surfaces as a bare red run
# indistinguishable from a real content alert. That happened once
# (actions:read 403). Collect what we can, then decide explicitly.
# `set +e` is deliberate, and `-e` here is actively harmful.
# GitHub runs this step as `bash -e {0}`, which combined with
# pipefail means ANY non-zero anywhere kills the script at that
# line with no output at all — a red run and an empty log. It bit
# twice: a 403 from the Actions API, then a `grep` with no match
# inside the mirrored-paths filter (grep exits 1 on no-match, which
# is a normal answer, not an error). This script decides everything
# explicitly below; it must never die silently mid-check.
set +e

# ---- hop 3: what production is serving -----------------------
LIVE=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/contents/src/content?ref=main" --jq '.sha' || true)
Expand Down Expand Up @@ -87,6 +91,15 @@ jobs:
if [ -n "$PUBLISHED" ]; then EXPECTED="$SHA"; break; fi
done

# On failure `gh` prints its error body to stdout, which would land
# in these vars and read as a bogus sha. Keep only real ones, so a
# broken call resolves to empty and trips the DEGRADED branch below
# instead of being compared as if it were data.
only_sha() { printf '%s' "${1:-}" | grep -Eox '[0-9a-f]{40}' || true; }
LIVE=$(only_sha "$LIVE")
TIP=$(only_sha "$TIP")
EXPECTED=$(only_sha "$EXPECTED")

Comment on lines +94 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Sanitize all API-derived values before continuing.

only_sha() protects LIVE, TIP, and EXPECTED, but a failed TIP_AT request can still place gh’s error body into TIP_AT; Line [135] then passes it to date -d, potentially leaving AGE invalid and aborting under set -u. Also validate MIRRORED from Line [103] as an exact 40-character SHA instead of accepting [0-9a-f]+ before the prefix comparison.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/content-pipeline-watchdog.yml around lines 94 - 102,
Sanitize every API-derived value before use: apply the existing only_sha helper
to TIP_AT before the date/AGE calculation, ensuring invalid or failed responses
produce the safe empty path rather than reaching date -d. Update MIRRORED
validation to require an exact 40-character lowercase SHA, matching only_sha,
before performing the prefix comparison.

MIRRORED=$(printf '%s' "$TIP_MSG" | sed -nE 's/.*mono@([0-9a-f]+).*/\1/p')

echo "expected mono@${EXPECTED:0:7} | mirrored @${MIRRORED:-none} | peanut-content ${TIP:0:7} | live ${LIVE:0:7}"
Expand Down
3 changes: 3 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ dependencies {
implementation "androidx.credentials:credentials:1.5.0"
implementation "androidx.credentials:credentials-play-services-auth:1.5.0"

// play install referrer — deferred deep linking (InstallReferrerPlugin.java)
implementation "com.android.installreferrer:installreferrer:2.2"

testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package me.peanut.wallet;

import android.os.Handler;
import android.os.Looper;

import com.android.installreferrer.api.InstallReferrerClient;
import com.android.installreferrer.api.InstallReferrerStateListener;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

import java.util.concurrent.atomic.AtomicBoolean;

/**
* exposes the play install referrer to JS for deferred deep linking: the web
* store-bounce appends ?referrer=<payload> to the play url, and after install
* the JS side (src/utils/deferred-link.ts) reads it back here exactly once.
* always resolves — {referrer: null} on any failure or after a 5s timeout
* (the referrer service can bind without ever calling back, and the JS side
* awaits this before finishing app init) — so the promise can never hang.
*/
@CapacitorPlugin(name = "InstallReferrer")
public class InstallReferrerPlugin extends Plugin {

private static final long TIMEOUT_MS = 5000;

@PluginMethod
public void getReferrer(PluginCall call) {
final InstallReferrerClient client = InstallReferrerClient.newBuilder(getContext()).build();
final AtomicBoolean resolved = new AtomicBoolean(false);
final Handler timeoutHandler = new Handler(Looper.getMainLooper());
final Runnable timeout = () -> resolveOnce(call, client, resolved, null);
timeoutHandler.postDelayed(timeout, TIMEOUT_MS);

try {
client.startConnection(new InstallReferrerStateListener() {
@Override
public void onInstallReferrerSetupFinished(int responseCode) {
timeoutHandler.removeCallbacks(timeout);
String referrer = null;
try {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
referrer = client.getInstallReferrer().getInstallReferrer();
}
// else: SERVICE_UNAVAILABLE / FEATURE_NOT_SUPPORTED / DEVELOPER_ERROR
} catch (Exception ignored) {}
resolveOnce(call, client, resolved, referrer);
}

@Override
public void onInstallReferrerServiceDisconnected() {
// one-shot read; resolve null instead of leaving the call pending
timeoutHandler.removeCallbacks(timeout);
resolveOnce(call, client, resolved, null);
}
});
} catch (Exception e) {
timeoutHandler.removeCallbacks(timeout);
resolveOnce(call, client, resolved, null);
}
}

private void resolveOnce(PluginCall call, InstallReferrerClient client, AtomicBoolean resolved, String referrer) {
if (!resolved.compareAndSet(false, true)) return;
try {
client.endConnection();
} catch (Exception ignored) {}
JSObject ret = new JSObject();
ret.put("referrer", referrer);
call.resolve(ret);
}
}
2 changes: 2 additions & 0 deletions android/app/src/main/java/me/peanut/wallet/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public class MainActivity extends BridgeActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
// app-local plugin, not auto-discovered — must register before super.onCreate
registerPlugin(InstallReferrerPlugin.class);
super.onCreate(savedInstanceState);

Bridge bridge = this.getBridge();
Expand Down
20 changes: 19 additions & 1 deletion ios/App/App/ClipboardDetectPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,28 @@ public class ClipboardDetectPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "ClipboardDetectPlugin"
public let jsName = "ClipboardDetect"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "hasStrings", returnType: CAPPluginReturnPromise)
CAPPluginMethod(name: "hasStrings", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "hasProbableWebUrl", returnType: CAPPluginReturnPromise)
]

@objc func hasStrings(_ call: CAPPluginCall) {
call.resolve(["value": UIPasteboard.general.hasStrings])
}

/*
* detectPatterns is also prompt-free: it reports pattern confidence
* without exposing content. Gates the deferred-deep-link clipboard read
* so only a probable web URL (the hand-off shape) triggers the real,
* prompt-raising read.
*/
@objc func hasProbableWebUrl(_ call: CAPPluginCall) {
UIPasteboard.general.detectPatterns(for: [\.probableWebURL]) { result in
switch result {
case .success(let patterns):
call.resolve(["value": patterns.contains(\.probableWebURL)])
case .failure:
call.resolve(["value": false])
}
}
}
}
138 changes: 138 additions & 0 deletions src/app/dev/deferred/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use client'

// dev tool for TASK-20772 deferred deep linking: inspect the hand-off state,
// build/copy payloads on web, read the raw android referrer, and simulate a
// restore from any raw string (the android dev loop — real referrer data only
// exists for play-delivered installs).
import { useCallback, useEffect, useState } from 'react'
import {
applyDeferredPayload,
buildDeferredPayload,
copyIOSHandoff,
iosHandoffString,
parseDeferredPayload,
playStoreUrlWithReferrer,
readInstallReferrer,
CONSUMED_KEY,
} from '@/utils/deferred-link'
import { getFromCookie } from '@/utils/general.utils'
import { getPlatform } from '@/utils/capacitor'
import { useAppLocale } from '@/i18n/app/AppIntlProvider'

export default function DeferredLinkDevPage() {
const { locale, setLocale } = useAppLocale()
const [state, setState] = useState<Record<string, string>>({})
const [payload, setPayload] = useState('')
const [rawReferrer, setRawReferrer] = useState('(not read)')
const [simulateInput, setSimulateInput] = useState('')
const [simulateResult, setSimulateResult] = useState('')
const [copied, setCopied] = useState(false)

const refresh = useCallback(() => {
setState({
platform: getPlatform(),
appLocale: locale,
consumedFlag: localStorage.getItem(CONSUMED_KEY) ?? '(unset)',
inviteCodeCookie: String(getFromCookie('inviteCode') ?? '(unset)'),
campaignTagCookie: String(getFromCookie('campaignTag') ?? '(unset)'),
})
}, [locale])

useEffect(() => refresh(), [refresh])

const readReferrer = async () => {
setRawReferrer((await readInstallReferrer()) ?? '(null / unavailable)')
}

const simulate = async () => {
const parsed = parseDeferredPayload(simulateInput)
if (!parsed) {
setSimulateResult('rejected (no pnutdl marker)')
} else {
const { dest, locale: restoredLocale } = applyDeferredPayload(parsed)
if (restoredLocale) await setLocale(restoredLocale)
setSimulateResult(
`applied ${JSON.stringify(parsed)} → dest: ${dest ?? 'none'}, locale: ${restoredLocale ?? 'none'}`
)
}
refresh()
}
Comment on lines +22 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f middleware.ts src
rg -n "NODE_ENV|process.env" src/app/dev --type=tsx --type=ts -g '!**/node_modules/**'
fd -t d dev src/app

Repository: peanutprotocol/peanut-ui

Length of output: 260


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== app/dev tree ==\n'
find src/app/dev -maxdepth 3 -type f | sort

printf '\n== app route/layout files near dev ==\n'
find src/app -maxdepth 3 \( -name 'layout.tsx' -o -name 'page.tsx' -o -name 'middleware.ts' -o -name 'route.ts' \) | sort | sed -n '1,200p'

printf '\n== search for dev-only gating ==\n'
rg -n "NODE_ENV|process\.env|DEV|development|production|draftMode|redirect\\(|notFound\\(" src/app src/middleware.ts src -g '!**/node_modules/**' || true

Repository: peanutprotocol/peanut-ui

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/app/dev/deferred/page.tsx ---'
cat -n src/app/dev/deferred/page.tsx | sed -n '1,220p'

echo
echo '--- src/app/dev tree files ---'
find src/app/dev -maxdepth 3 -type f | sort | xargs -I{} sh -c 'echo; echo "### {}"; cat -n "{}" | sed -n "1,220p"'

echo
echo '--- search for route-level gating around dev paths ---'
rg -n "redirect\\(|notFound\\(|draftMode|process\\.env|NODE_ENV|export const dynamic|export const runtime|cookies\\(|headers\\(" src/app/dev src/app -g '!**/node_modules/**' || true

Repository: peanutprotocol/peanut-ui

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/proxy.ts ---'
cat -n src/proxy.ts | sed -n '1,120p'

echo
echo '--- deferred-link utilities ---'
rg -n "export .*applyDeferredPayload|export .*parseDeferredPayload|export .*CONSUMED_KEY|inviteCode|campaignTag|setCookie|cookie" src/utils/deferred-link* src/utils -g '!**/node_modules/**' || true

echo
echo '--- src/utils/deferred-link file(s) ---'
for f in $(git ls-files 'src/utils/deferred-link*'); do
  echo
  echo "### $f"
  cat -n "$f" | sed -n '1,260p'
done

Repository: peanutprotocol/peanut-ui

Length of output: 27478


Gate /dev/deferred behind a production check. /dev/* is already reachable on peanut.me, so this page can still rewrite inviteCode/campaignTag cookies and clear deferredLinkConsumed in production. If it’s meant to stay internal, re-add the /dev block in the proxy or remove the mutating controls from the shipped page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/dev/deferred/page.tsx` around lines 22 - 59, Gate DeferredLinkDevPage
from production access by restoring the existing /dev proxy restriction;
alternatively, remove its mutating controls and related state-changing actions,
including simulate/apply behavior that can rewrite cookies or clear
deferredLinkConsumed. Keep the diagnostic functionality only where it cannot
modify production state.


return (
<div className="mx-auto flex max-w-2xl flex-col gap-6 p-6 font-mono text-sm">
<h1 className="text-lg font-bold">deferred deep link — dev</h1>

<section>
<h2 className="font-bold">state</h2>
<pre className="whitespace-pre-wrap border border-n-1 p-2">{JSON.stringify(state, null, 2)}</pre>
<div className="flex gap-2">
<button className="border border-n-1 px-2 py-1" onClick={refresh}>
refresh
</button>
<button
className="border border-n-1 px-2 py-1"
onClick={() => {
localStorage.removeItem(CONSUMED_KEY)
refresh()
}}
>
reset consumed flag
</button>
</div>
</section>

<section>
<h2 className="font-bold">web → store hand-off</h2>
<button
className="border border-n-1 px-2 py-1"
onClick={() => setPayload(buildDeferredPayload('/home'))}
>
build payload from current context (dest=/home)
</button>
{payload && (
<pre className="whitespace-pre-wrap break-all border border-n-1 p-2">
payload: {payload}
{'\n\n'}play url: {playStoreUrlWithReferrer(payload)}
{'\n\n'}ios hand-off: {iosHandoffString(payload)}
</pre>
)}
{payload && (
<button
className="border border-n-1 px-2 py-1"
onClick={async () => {
await copyIOSHandoff(payload)
setCopied(true)
}}
>
{copied ? 'copied ✓' : 'copy ios hand-off to clipboard'}
</button>
)}
</section>

<section>
<h2 className="font-bold">native: raw install referrer</h2>
<button className="border border-n-1 px-2 py-1" onClick={readReferrer}>
read raw referrer (android native only)
</button>
<pre className="whitespace-pre-wrap break-all border border-n-1 p-2">{rawReferrer}</pre>
</section>

<section>
<h2 className="font-bold">simulate restore</h2>
<textarea
className="w-full border border-n-1 p-2"
rows={3}
placeholder="pnutdl=1&lang=es-419&invite=test&dest=%2Fhome — or a full hand-off url"
value={simulateInput}
onChange={(e) => setSimulateInput(e.target.value)}
/>
<button className="border border-n-1 px-2 py-1" onClick={simulate}>
parse + apply
</button>
{simulateResult && (
<pre className="whitespace-pre-wrap break-all border border-n-1 p-2">{simulateResult}</pre>
)}
</section>
</div>
)
}
2 changes: 2 additions & 0 deletions src/constants/general.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,5 @@ export const ENS_NAME_REGEX = /^(?:[-a-zA-Z0-9]+\.)+[-a-zA-Z0-9]+$/

// Mirrors the backend username minimum (USERNAME_REGEX = /^[a-z][a-z0-9]{3,11}$/).
export const USERNAME_MIN_LENGTH = 4

export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=me.peanut.wallet'
Loading
Loading